diff --git a/.changeset/build-output-seeding.md b/.changeset/build-output-seeding.md new file mode 100644 index 0000000..f75fd74 --- /dev/null +++ b/.changeset/build-output-seeding.md @@ -0,0 +1,13 @@ +--- +"@leejpsd/nextjs-cache-handler": minor +--- + +Build-output cache seeding: `seedBuildOutput()` (new `/seed` entry point) and +`npx nextjs-cache-handler seed` walk `.next/` after a build and insert +prerendered App Router routes (including PPR segment data), Pages Router +routes, and fetch-cache entries into Redis in the handler's own record +format — with NX semantics so entries already written by live instances are +never overwritten. A fresh deployment's first requests are cache HITs +instead of a regeneration stampede (verified on a real Next 16 app: cold +server + seeded Redis → first request `x-nextjs-cache: HIT`). The +`RedisClientLike.set` contract gains an optional `NX` flag. diff --git a/.changeset/init-doctor-cli.md b/.changeset/init-doctor-cli.md new file mode 100644 index 0000000..8b78ebe --- /dev/null +++ b/.changeset/init-doctor-cli.md @@ -0,0 +1,11 @@ +--- +"@leejpsd/nextjs-cache-handler": minor +--- + +New `nextjs-cache-handler` CLI (zero-dependency): `init` detects the Next.js +version and Redis client, generates the handler wrapper shims, shows the +next.config keys to add (never edits it), appends env templates, and injects +the agent rules block into CLAUDE.md/AGENTS.md idempotently (`--yes` to +apply, `--skills` to install the agent skill locally). `doctor` verifies +Redis connectivity, inspects cache key namespaces, and runs a write/read +round-trip — the first command an agent should reach for when debugging. diff --git a/.changeset/tag-pubsub.md b/.changeset/tag-pubsub.md new file mode 100644 index 0000000..ca39dbb --- /dev/null +++ b/.changeset/tag-pubsub.md @@ -0,0 +1,13 @@ +--- +"@leejpsd/nextjs-cache-handler": minor +--- + +Opt-in push-based tag propagation (`tagPubSub: true`, plural handler): +`updateTags()` publishes invalidations on a namespaced channel and every +instance maintains a subscription on a dedicated duplicate connection, +updating its local tag mirror in ~3 ms (measured cross-instance over real +Redis with both redis@5 and ioredis) instead of waiting for the next +`refreshTags()` scan (~seconds). The scan keeps running as the consistency +safety net, so a dropped subscription degrades to the previous behavior — +never to staleness. Cluster clients fall back to polling with a one-time +warning. `RedisClientLike` gains optional `publish`/`subscribe`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3534e6..1b70189 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,3 +93,23 @@ jobs: - name: Print snapshot fingerprint run: | sha256sum docs/next16-spec.md + + cluster: + name: Redis Cluster e2e + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: npm + - run: npm ci --no-audit --no-fund + - name: Install redis-server + run: sudo apt-get update -qq && sudo apt-get install -y -qq redis-server redis-tools + - name: Start 3-master cluster + run: scripts/cluster-test-env.sh up + - name: Cluster e2e tests + run: npm run test:cluster + - if: always() + run: scripts/cluster-test-env.sh down diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4bc778c..6e622b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,11 @@ name: Release # below to enable) on: workflow_dispatch: + inputs: + publish_mcp: + description: "Publish mcp/ (@leejpsd/nextjs-cache-handler-mcp) instead of the main package" + type: boolean + default: false # push: # branches: [main] @@ -60,7 +65,16 @@ jobs: INTEGRATION_REDIS_URL: redis://127.0.0.1:6390 run: npm run test:integration + - name: Publish MCP package (trusted publishing) + if: ${{ inputs.publish_mcp }} + working-directory: mcp + run: | + npm ci --no-audit --no-fund + npm test + npm publish --access public --provenance + - name: Create Release Pull Request or Publish to npm + if: ${{ !inputs.publish_mcp }} uses: changesets/action@v1 with: # publish: gets called when there are no changesets left to apply. diff --git a/.gitignore b/.gitignore index 0795e2d..69c66cf 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ coverage/ *.tsbuildinfo .cache/ .turbo/ +mcp/node_modules +mcp/dist diff --git a/README.md b/README.md index a0e0af1..f5aa7b4 100644 --- a/README.md +++ b/README.md @@ -245,8 +245,10 @@ Full reference: [`docs/api.md`](./docs/api.md). on the same hash slot. Without `hashTag`, cluster deployments will hit `CROSSSLOT Keys in request don't hash to the same slot`. The flag wraps the namespace in `{}` so every key for a given deploy hashes together. - Cluster support is implemented but **not yet load-tested at production - scale**; PRs welcome. + Cluster support is validated by a dedicated e2e suite against a real + 3-master cluster (`npm run test:cluster`, also in CI) — covering the + multi-key Lua scripts, per-master SCAN propagation, and both handlers. + Not yet load-tested at production scale. - [ ] **Redis `maxmemory-policy: allkeys-lru` or `noeviction`** — if you need bounded memory, choose `allkeys-lru`. Otherwise `noeviction` keeps tag indices intact. @@ -261,7 +263,7 @@ Full reference: [`docs/api.md`](./docs/api.md). | Service | How to use | Tested? | |---|---|---| | **Self-hosted Redis 7+** | `{ type: "redis", url }` or `{ type: "ioredis", url }` | ✅ AWS ElastiCache 24h soak | -| **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | unit-tested, not yet load-tested at scale | +| **Redis Cluster** | `{ type: "cluster", nodes }` + `hashTag: true` | ✅ e2e-tested against a real 3-master cluster (CI); not yet load-tested at scale | | **Upstash Redis** | `{ type: "redis", url: "rediss://..." }` (TLS auto-detected) | not yet validated, expected to work via the standard Redis protocol | | **AWS ElastiCache (replication group)** | `{ type: "redis", url: "rediss://..." }` | ✅ reference deployment (re-verified 2026-08-01, Seoul) | | **Redis Sentinel** | `{ type: "sentinel", sentinels, name }` | ✅ local master/replica failover drill | diff --git a/bin/cli.cjs b/bin/cli.cjs new file mode 100755 index 0000000..bb6ef1e --- /dev/null +++ b/bin/cli.cjs @@ -0,0 +1,10 @@ +#!/usr/bin/env node +// Thin launcher for the built CLI (keeps the tsup build shebang-free). +const { main } = require("../dist/cli/index.cjs"); +main().then( + (code) => process.exit(code), + (err) => { + console.error("[error]", err && err.message ? err.message : err); + process.exit(1); + } +); diff --git a/docs/api.md b/docs/api.md index 75a076b..996972a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,6 +15,7 @@ Package: `@leejpsd/nextjs-cache-handler` — Redis cache handler for Next.js | `.../client/ioredis` | `adaptIoredis`, `adaptCluster`, `createIoredisClient`, `createIoredisSentinel`, `createIoredisCluster` | wrap your own ioredis / Cluster / Sentinel client | | `.../ops` | `getMetricSnapshot` | process-local metric counters for health endpoints | | `.../otel` | `createOtelMetricEmitter` | built-in OpenTelemetry adapter (needs `@opentelemetry/api` in your app) | +| `.../seed` | `seedBuildOutput` | seed `.next` build output into Redis (NX-protected) — also `npx nextjs-cache-handler seed` | ## Factories @@ -48,6 +49,7 @@ ctx contract are accepted (`ctx.cacheControl.revalidate` / `kind` and | `singleFlightLockTtlSec` | `number` | `10` | lock TTL for `singleFlight` | | `isBuildPhase` | `() => boolean` | `NEXT_PHASE === "phase-production-build"` | build-phase gate override | | `hashTag` | `boolean` | `false` | wrap namespace in `{}` — **required on Redis Cluster** (multi-key Lua) | +| `tagPubSub` | `boolean` | `false` | push-based cross-instance tag propagation (plural handler; ~3ms); scan polling remains the safety net; unavailable on Cluster | | `onMetric` | `(event: MetricEvent) => void` | — | telemetry hook; emitter errors are swallowed | | `logger` | `Logger` | console (warn+) | injectable 4-level logger | diff --git a/docs/release-checklist-0.4.0.md b/docs/release-checklist-0.4.0.md new file mode 100644 index 0000000..b039f5e --- /dev/null +++ b/docs/release-checklist-0.4.0.md @@ -0,0 +1,51 @@ +# 0.4.0 + MCP Release Checklist (승인 대기) + +> 상태: **모든 작업 완료, 사용자 최종 승인 대기.** 승인 시 아래 순서대로 +> 실행하면 배포까지 자동으로 이어진다. (0.3.4 = Phase 1 에이전트 자산 +> 패치는 별도 예약 배포 — 이 문서와 무관하게 진행됨) + +## 승인 시 실행 순서 (0.4.0 — 자동화됨) + +1. PR `next/0.4 → main` 머지 (CI green 확인 후) +2. `gh workflow run release.yml --ref main` → "Version Packages" PR 생성 + - **버전이 0.4.0 (minor)인지, changeset 4개(CLI/seed/tagPubSub + 잔여 패치)가 + 소비되는지 diff 확인** +3. Version PR 머지 → `gh workflow run release.yml --ref main` → npm 0.4.0 publish + (Trusted Publishing, 토큰 불필요) +4. 데모 리포 의존성 `^0.4.0` 갱신 + README/문서의 0.4 기능 반영 + +## MCP 첫 배포 (@leejpsd/nextjs-cache-handler-mcp 0.1.0 — 사용자 액션 1회 필요) + +신규 패키지는 Trusted Publishing을 미리 설정할 수 없어 **첫 publish만** 수동: + +1. `cd mcp && npm run test` (빌드+스모크 재확인) +2. `npm publish --access public` (npm 로그인/OTP 필요 — 사용자) +3. 이후 npmjs.com에서 이 패키지에도 Trusted Publisher 설정 + (leejpsd / nextjs-cache-handler / release.yml) → 다음부터 자동화 가능 + +## Phase 4 — publish 후 발견성 배포 (승인 후 제가 실행 가능) + +- [ ] 공식 MCP 레지스트리 + Smithery/mcp.so 등록 (mcp/README 기반) +- [ ] skills 레지스트리 노출 확인 (`npx skills add leejpsd/nextjs-cache-handler`) +- [ ] 데모 리포 `.mcp.json` 예제 추가 +- [ ] 홍보 재개 (docs/ 초안: fortedigital #152 코멘트 1순위 — 0.4.0 실측 + 수치로 업데이트: 시딩 첫요청 HIT, 전파 3ms, Cluster e2e) + +## 이번 브랜치에 담긴 것 (검증 증거) + +| 항목 | 검증 | +|---|---| +| init/doctor CLI | 유닛 10 + 실Redis doctor 2 + 실제 bin 스모크 | +| 빌드 캐시 시딩 (`/seed`, CLI seed) | 유닛 6 + **실앱 e2e: 콜드 서버 첫 요청 `x-nextjs-cache: HIT`** (15 라우트 + fetch 1건 시딩) | +| tagPubSub 전파 | 유닛 4 + 실Redis 통합 2 — **크로스 인스턴스 3ms** (redis@5/ioredis 모두; 기존 스캔 ~2.1s) | +| Redis Cluster e2e | 실 3-마스터 클러스터 5 테스트 (Lua/CROSSSLOT, per-master SCAN, ISR, pubsub 폴백) + CI 잡 신설 | +| MCP 서버 (7 도구) | stdio JSON-RPC 스모크 + 실Redis 라이브 콜 (health/tag_state/dry-run) | + +전체 스위트: 유닛 153 + 통합 25 + 클러스터 5. 코어 zero-dep 유지 +(CLI는 node 내장만, MCP는 별도 패키지). + +## 남은 리스크 / 알려진 한계 + +- tagPubSub는 Cluster 클라이언트에서 폴링 폴백 (경고 1회) — 문서화됨 +- 시딩: PPR 세그먼트가 불완전한 라우트는 안전하게 스킵 (카운트 보고) +- CI cluster 잡은 이번 PR에서 첫 실행 — 실패 시 머지 전 수정 diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..edce2b2 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,50 @@ +# @leejpsd/nextjs-cache-handler-mcp + +MCP (Model Context Protocol) server for +[`@leejpsd/nextjs-cache-handler`](https://www.npmjs.com/package/@leejpsd/nextjs-cache-handler) +Redis caches. Lets AI agents (Claude Code, Cursor, …) inspect and operate a +running deployment's Next.js cache: *"why isn't this page updating?"* +becomes a `tag_state` call instead of guesswork. + +## Setup + +Project `.mcp.json` (Claude Code picks this up automatically): + +```json +{ + "mcpServers": { + "nextjs-cache": { + "command": "npx", + "args": ["-y", "@leejpsd/nextjs-cache-handler-mcp"], + "env": { + "REDIS_URL": "redis://127.0.0.1:6379", + "DEPLOYMENT_VERSION": "your-deploy-id" + } + } + } +} +``` + +Runs locally over stdio and connects to YOUR Redis — nothing is hosted. + +> **AWS/ElastiCache note**: ElastiCache is VPC-internal, so a locally +> running MCP server needs an SSH tunnel/bastion (point REDIS_URL at the +> tunnel). Agents running inside the VPC (CI, in-cluster) connect directly. +> Pair with the AWS agent skills/MCP: they handle the infrastructure, this +> server handles cache semantics. + +## Tools + +| Tool | What it answers | Writes? | +|---|---|---| +| `cache_health` | Is Redis up? What's cached, per layer/kind? | no | +| `cache_search` | Which keys match this pattern? | no | +| `cache_inspect` | Decode one entry: kind, age, tags, TTL, compression, sizes | no | +| `tag_state` | Is this tag invalidated right now, on BOTH cache layers? | no | +| `explain_key` | Parse a raw Redis key into layer/kind/namespace/key | no | +| `simulate_swr` | Would this entry be fresh / stale / expired, and what happens on read? | no | +| `invalidate_tag` | Soft (SWR) or hard invalidation — **dry-run unless `confirm: true`** | gated | + +Env: `REDIS_URL` (required), `DEPLOYMENT_VERSION` (recommended — scopes +namespace-aware tools), `CACHE_KEY_PREFIX` / `ISR_KEY_PREFIX` (only when the +handlers use custom prefixes). diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 0000000..888508d --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,2709 @@ +{ + "name": "@leejpsd/nextjs-cache-handler-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@leejpsd/nextjs-cache-handler-mcp", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "redis": "^5.0.0", + "zod": "^3.24.0" + }, + "bin": { + "nextjs-cache-handler-mcp": "dist/server.js" + }, + "devDependencies": { + "tsup": "^8.3.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.0.12.tgz", + "integrity": "sha512-eWpQYr67tqJLeaSUl0Q+TquuYfUdTibpOJlUMV2FfUP7+KqCC5TufnwnlXL6mobZBJbGAYRd7ZvEBDCbLInjhg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@redis/bloom": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.12.1.tgz", + "integrity": "sha512-PUUfv+ms7jgPSBVoo/DN4AkPHj4D5TZSd6SbJX7egzBplkYUcKmHRE8RKia7UtZ8bSQbLguLvxVO+asKtQfZWA==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/client": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.12.1.tgz", + "integrity": "sha512-7aPGWeqA3uFm43o19umzdl16CEjK/JQGtSXVPevplTaOU3VJA/rseBC1QvYUz9lLDIMBimc4SW/zrW4S89BaCA==", + "license": "MIT", + "dependencies": { + "cluster-key-slot": "1.1.2" + }, + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-5.12.1.tgz", + "integrity": "sha512-eOze75esLve4vfqDel7aMX08CNaiLLQS2fV8mpRN9NxPe1rVR4vQyYiW/OgtGUysF6QOr9ANhfxABKNOJfXdKg==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/search": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-5.12.1.tgz", + "integrity": "sha512-ItlxbxC9cKI6IU1TLWoczwJCRb6TdmkEpWv05UrPawqaAnWGRu3rcIqsc5vN483T2fSociuyV1UkWIL5I4//2w==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@redis/time-series": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.12.1.tgz", + "integrity": "sha512-c6JL6E3EcZJuNqKFz+KM+l9l5mpcQiKvTwgA3blt5glWJ8hjDk0yeHN3beE/MpqYIQ8UEX44ItQzgkE/gCBELQ==", + "license": "MIT", + "engines": { + "node": ">= 18.19.0" + }, + "peerDependencies": { + "@redis/client": "^5.12.1" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bundle-require": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", + "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "load-tsconfig": "^0.2.3" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "peerDependencies": { + "esbuild": ">=0.18" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", + "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fix-dts-default-cjs-exports": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", + "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "rollup": "^4.34.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.33", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.33.tgz", + "integrity": "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.5.tgz", + "integrity": "sha512-2E5L2yRp03FnwreJLJX8/r7mHiZICCf8kG7fAsTWkSQTDAcc46NIZoQLKy+EJ8sPoJlxyS4OQR5H70LjIZZlIQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-tsconfig": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/load-tsconfig/-/load-tsconfig-0.2.5.tgz", + "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/redis": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/redis/-/redis-5.12.1.tgz", + "integrity": "sha512-LDsoVvb/CpoV9EN3FXvgvSHNJWuCIzl9MiO3ppOevuGLpSGJhwfQjpEwfFJcQvNSddHADDdZaWx0HnmMxRXG7g==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "5.12.1", + "@redis/client": "5.12.1", + "@redis/json": "5.12.1", + "@redis/search": "5.12.1", + "@redis/time-series": "5.12.1" + }, + "engines": { + "node": ">= 18.19.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tsup": { + "version": "8.5.1", + "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz", + "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-require": "^5.1.0", + "cac": "^6.7.14", + "chokidar": "^4.0.3", + "consola": "^3.4.0", + "debug": "^4.4.0", + "esbuild": "^0.27.0", + "fix-dts-default-cjs-exports": "^1.0.0", + "joycon": "^3.1.1", + "picocolors": "^1.1.1", + "postcss-load-config": "^6.0.1", + "resolve-from": "^5.0.0", + "rollup": "^4.34.8", + "source-map": "^0.7.6", + "sucrase": "^3.35.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.11", + "tree-kill": "^1.2.2" + }, + "bin": { + "tsup": "dist/cli-default.js", + "tsup-node": "dist/cli-node.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@microsoft/api-extractor": "^7.36.0", + "@swc/core": "^1", + "postcss": "^8.4.12", + "typescript": ">=4.5.0" + }, + "peerDependenciesMeta": { + "@microsoft/api-extractor": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "postcss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..ba5578f --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,45 @@ +{ + "name": "@leejpsd/nextjs-cache-handler-mcp", + "version": "0.1.1", + "description": "MCP server for inspecting and operating @leejpsd/nextjs-cache-handler Redis caches \u2014 health, key inspection, tag state, SWR simulation, and dry-run-first invalidation for AI agents.", + "keywords": [ + "mcp", + "modelcontextprotocol", + "nextjs", + "redis", + "cache", + "agent" + ], + "license": "MIT", + "author": "Eddy Lee ", + "repository": { + "type": "git", + "url": "git+https://github.com/leejpsd/nextjs-cache-handler.git", + "directory": "mcp" + }, + "type": "module", + "bin": { + "nextjs-cache-handler-mcp": "./dist/server.js" + }, + "files": [ + "dist/", + "README.md" + ], + "scripts": { + "build": "tsup", + "test": "npm run build && node test/smoke.mjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.12.0", + "redis": "^5.0.0", + "zod": "^3.24.0" + }, + "devDependencies": { + "tsup": "^8.3.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/mcp/src/server.ts b/mcp/src/server.ts new file mode 100644 index 0000000..a47fc96 --- /dev/null +++ b/mcp/src/server.ts @@ -0,0 +1,349 @@ +/** + * MCP server for @leejpsd/nextjs-cache-handler caches. + * + * Gives AI agents safe, structured access to a running deployment's Redis + * cache: health, key search/inspection, tag invalidation state, SWR + * simulation, and (explicitly confirmed) invalidation. + * + * Env: REDIS_URL (required), DEPLOYMENT_VERSION (optional — narrows + * namespace-aware tools), CACHE_KEY_PREFIX / ISR_KEY_PREFIX (optional + * overrides, defaults next-cache: / next-incremental:). + * + * All tools are read-only except `invalidate_tag`, which is DRY-RUN unless + * `confirm: true` is passed. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; +import { createClient, type RedisClientType } from "redis"; +import { gunzipSync, brotliDecompressSync } from "node:zlib"; + +const PLURAL_PREFIX = process.env.CACHE_KEY_PREFIX ?? "next-cache:"; +const ISR_PREFIX = process.env.ISR_KEY_PREFIX ?? "next-incremental:"; +const NS = process.env.DEPLOYMENT_VERSION; +/** Mirror of the handler's hashTag option — REQUIRED when the app sets + * hashTag: true (Redis Cluster), otherwise every 'use cache' key this + * server computes is wrong. */ +const HASH_TAG = process.env.HASH_TAG === "true"; +const wrapNs = (ns: string): string => + HASH_TAG && !ns.startsWith("{") ? `{${ns}}` : ns; +/** Escape Redis MATCH glob metacharacters (agent-supplied tags). */ +const escapeMatch = (v: string): string => v.replace(/[\\*?[\]]/g, "\\$&"); + +let clientPromise: Promise | null = null; +async function redis(): Promise { + if (!clientPromise) { + const url = process.env.REDIS_URL; + if (!url) throw new Error("REDIS_URL is not set"); + clientPromise = (async () => { + const c = createClient({ url, socket: { connectTimeout: 3000 } }); + c.on("error", () => {}); + await c.connect(); + return c as RedisClientType; + })(); + } + return clientPromise; +} + +const MAX_INSPECT_BYTES = 2 * 1024 * 1024; // refuse to fully decode above this +const MAX_DECOMPRESSED = 16 * 1024 * 1024; // zlib bomb guard + +function decompress(raw: string): string { + if (raw.startsWith("__ncgz__:")) { + return gunzipSync(Buffer.from(raw.slice(9), "base64"), { + maxOutputLength: MAX_DECOMPRESSED, + }).toString("utf8"); + } + if (raw.startsWith("__ncbr__:")) { + return brotliDecompressSync(Buffer.from(raw.slice(9), "base64"), { + maxOutputLength: MAX_DECOMPRESSED, + }).toString("utf8"); + } + return raw; +} + +function text(s: unknown) { + return { + content: [ + { + type: "text" as const, + text: typeof s === "string" ? s : JSON.stringify(s, null, 2), + }, + ], + }; +} + +function explain(key: string): Record { + // ISR tag states are un-namespaced by design; the whole rest is the tag + // (which may itself contain colons). + if (key.startsWith(`${ISR_PREFIX}tag:`)) { + return { + layer: "cacheHandler (ISR)", + kind: "tag", + namespace: "(none — cross-deploy)", + key: key.slice(`${ISR_PREFIX}tag:`.length), + }; + } + for (const prefix of [PLURAL_PREFIX, ISR_PREFIX]) { + if (!key.startsWith(prefix)) continue; + const rest = key.slice(prefix.length); + const m = rest.match(/^([a-z-]+):(?:\{([^}]+)\}|([^:]+)):(.*)$/s); + if (m) { + return { + layer: prefix === PLURAL_PREFIX ? "cacheHandlers ('use cache')" : "cacheHandler (ISR)", + kind: m[1]!, + namespace: m[2] ?? m[3] ?? "", + key: m[4] ?? "", + }; + } + // Un-namespaced (ISR tag states are cross-deploy by design). + const m2 = rest.match(/^([a-z-]+):(.*)$/s); + if (m2) { + return { + layer: prefix === PLURAL_PREFIX ? "cacheHandlers" : "cacheHandler (ISR)", + kind: m2[1]!, + namespace: "(none — cross-deploy)", + key: m2[2] ?? "", + }; + } + } + return { layer: "unknown", kind: "?", namespace: "?", key }; +} + +const server = new McpServer({ + name: "nextjs-cache-handler", + version: "0.1.0", +}); + +server.tool( + "cache_health", + "Ping Redis and summarize cache key counts per layer/kind. First tool to run when debugging cache issues.", + {}, + async () => { + const c = await redis(); + const t0 = Date.now(); + await c.ping(); + const pingMs = Date.now() - t0; + const counts: Record = {}; + for await (const keys of c.scanIterator({ MATCH: "next-*", COUNT: 500 })) { + for (const k of Array.isArray(keys) ? keys : [keys]) { + const e = explain(String(k)); + const bucket = `${e.layer} / ${e.kind}` + (NS && e.namespace === NS ? " (this deploy)" : ""); + counts[bucket] = (counts[bucket] ?? 0) + 1; + } + } + return text({ pingMs, deployment: NS ?? "(DEPLOYMENT_VERSION not set)", counts }); + } +); + +server.tool( + "cache_search", + "Scan cache keys by glob pattern (e.g. 'next-incremental:entry:*/blog*'). Returns up to `limit` keys.", + { + pattern: z.string().describe("Redis MATCH glob, e.g. next-cache:entry:*"), + limit: z.number().int().min(1).max(500).default(50), + }, + async ({ pattern, limit }) => { + const c = await redis(); + const out: string[] = []; + for await (const keys of c.scanIterator({ MATCH: pattern, COUNT: 500 })) { + for (const k of Array.isArray(keys) ? keys : [keys]) { + out.push(String(k)); + if (out.length >= limit) return text({ keys: out, truncated: true }); + } + } + return text({ keys: out, truncated: false }); + } +); + +server.tool( + "cache_inspect", + "Decode a cache entry: kind, timestamps, tags, TTL, sizes, compression. Values are summarized, never dumped in full.", + { key: z.string().describe("Full Redis key of a cache entry") }, + async ({ key }) => { + const c = await redis(); + const size = await c.strLen(key); + if (size === 0) { + const exists = await c.exists(key); + if (!exists) return text({ key, exists: false }); + } + if (size > MAX_INSPECT_BYTES) { + return text({ + key, + explained: explain(key), + exists: true, + storedBytes: size, + ttlSeconds: await c.ttl(key), + note: `value larger than ${MAX_INSPECT_BYTES} bytes — full decode refused; sizes/TTL only`, + }); + } + const [raw, ttl] = await Promise.all([c.get(key), c.ttl(key)]); + if (raw === null) return text({ key, exists: false }); + const compressed = raw.startsWith("__ncgz__:") ? "gzip" : raw.startsWith("__ncbr__:") ? "brotli" : "none"; + let parsed: Record | null = null; + try { + parsed = JSON.parse(decompress(raw)) as Record; + } catch { + /* non-JSON (tag marker etc.) */ + } + const value = (parsed?.value ?? parsed) as Record | null; + return text({ + key, + explained: explain(key), + exists: true, + ttlSeconds: ttl, + storedBytes: raw.length, + compression: compressed, + lastModified: parsed?.lastModified ?? null, + ageSeconds: + typeof parsed?.lastModified === "number" + ? Math.round((Date.now() - (parsed.lastModified as number)) / 1000) + : null, + revalidateSec: parsed?.revalidateSec ?? null, + entryTimestamp: (parsed?.timestamp as number | undefined) ?? null, + kind: (value?.kind as string | undefined) ?? null, + tags: (parsed?.tags as unknown) ?? (value?.tags as unknown) ?? null, + htmlBytes: typeof value?.html === "string" ? (value.html as string).length : null, + }); + } +); + +server.tool( + "tag_state", + "Show invalidation state for a tag across BOTH cache layers: the ISR tag state (stale/expired timestamps) and the 'use cache' tag-expiration marker. Answers 'is this tag invalidated right now, and since when?'", + { tag: z.string() }, + async ({ tag }) => { + const c = await redis(); + const isrKey = `${ISR_PREFIX}tag:${tag}`; + const isrRaw = await c.get(isrKey); + let plural: Record = {}; + if (NS) { + const markerKey = `${PLURAL_PREFIX}tag-expiration:${wrapNs(NS)}:${tag}`; + const v = await c.get(markerKey); + plural = { markerKey, invalidatedAt: v ? Number(v) : null }; + } else { + const found: Record = {}; + for await (const keys of c.scanIterator({ + MATCH: `${PLURAL_PREFIX}tag-expiration:*:${escapeMatch(tag)}`, + COUNT: 500, + })) { + for (const k of Array.isArray(keys) ? keys : [keys]) { + const v = await c.get(String(k)); + if (v) found[String(k)] = Number(v); + } + } + plural = { markersByNamespace: found }; + } + return text({ + tag, + isr: { key: isrKey, state: isrRaw ? (JSON.parse(isrRaw) as unknown) : null }, + useCache: plural, + hint: "ISR: 'expired' hits all kinds, 'stale' is SWR. 'use cache': entries older than invalidatedAt serve stale-while-revalidate.", + }); + } +); + +server.tool( + "explain_key", + "Parse a Redis key into layer / kind / namespace / cache key.", + { key: z.string() }, + async ({ key }) => text(explain(key)) +); + +server.tool( + "simulate_swr", + "Given entry timing values, classify freshness the way the handler does (fresh / stale / expired) and explain what a read would do.", + { + timestampMs: z.number().describe("entry.timestamp (ms epoch)"), + revalidateSec: z.number(), + expireSec: z.number().describe("0 means 'never hard-expire'"), + nowMs: z.number().optional(), + }, + async ({ timestampMs, revalidateSec, expireSec, nowMs }) => { + const now = nowMs ?? Date.now(); + const ageMs = now - timestampMs; + const revMs = Math.max(0, revalidateSec) * 1000; + const rawExpMs = expireSec === 0 ? Number.POSITIVE_INFINITY : Math.max(0, expireSec) * 1000; + const expMs = Math.max(rawExpMs, revMs); + const freshness = ageMs < 0 || ageMs <= revMs ? "fresh" : ageMs <= expMs ? "stale" : "expired"; + const outcome = + freshness === "fresh" + ? "served as a HIT" + : freshness === "stale" + ? "served immediately AND Next schedules a background re-render (SWR)" + : "miss — entry evicted, blocking regeneration"; + return text({ ageSeconds: Math.round(ageMs / 1000), freshness, outcome }); + } +); + +server.tool( + "invalidate_tag", + "Invalidate a tag across both layers. DRY-RUN by default: shows exactly what would be written/deleted. Pass confirm=true to execute. mode 'soft' = stale-while-revalidate (revalidateTag(tag,'max') semantics); 'hard' = delete entries now.", + { + tag: z.string(), + mode: z.enum(["soft", "hard"]).default("soft"), + confirm: z.boolean().default(false), + }, + async ({ tag, mode, confirm }) => { + const c = await redis(); + const now = Date.now(); + const namespaces: string[] = NS ? [wrapNs(NS)] : []; + if (namespaces.length === 0) { + for await (const keys of c.scanIterator({ MATCH: `${PLURAL_PREFIX}tag:*:${escapeMatch(tag)}`, COUNT: 500 })) { + for (const k of Array.isArray(keys) ? keys : [keys]) { + const m = String(k).match(/tag:(\{[^}]+\}|[^:]+):/); + // Keep braces when discovered — they are part of the key shape. + const ns = m?.[1]; + if (ns && !namespaces.includes(ns)) namespaces.push(ns); + } + } + } + + const plan: string[] = [ + `SET ${ISR_PREFIX}tag:${tag} ${JSON.stringify(mode === "hard" ? { expired: now } : { stale: now })} (ISR tag state)`, + ]; + for (const ns of namespaces) { + plan.push(`SET ${PLURAL_PREFIX}tag-expiration:${ns}:${tag} ${now} (use-cache marker)`); + if (mode === "hard") plan.push(`DEL members of ${PLURAL_PREFIX}tag:${ns}:${tag} (tagged entries)`); + } + + const warnings: string[] = []; + if (!NS && namespaces.length === 0) { + warnings.push( + "No 'use cache' namespaces discovered (set DEPLOYMENT_VERSION to target one) — only the ISR tag state will be written." + ); + } + if (mode === "hard") { + warnings.push( + "Hard mode here is best-effort (SMEMBERS+DEL), not the handler's atomic Lua — a concurrent set() can survive." + ); + } + + if (!confirm) { + return text({ dryRun: true, mode, plan, warnings, note: "Pass confirm=true to execute." }); + } + + await c.set( + `${ISR_PREFIX}tag:${tag}`, + JSON.stringify(mode === "hard" ? { expired: now } : { stale: now }), + { EX: 60 * 60 * 24 * 365 } + ); + let deleted = 0; + for (const ns of namespaces) { + await c.set(`${PLURAL_PREFIX}tag-expiration:${ns}:${tag}`, String(now), { EX: 604800 }); + if (mode === "hard") { + const setKey = `${PLURAL_PREFIX}tag:${ns}:${tag}`; + const members = await c.sMembers(setKey); + if (members.length > 0) deleted += await c.del(members); + await c.del(setKey); + } + // Push notification for tagPubSub subscribers, if any. + await c.publish(`${PLURAL_PREFIX}inval:${ns}`, JSON.stringify({ t: [tag], ts: now })); + } + return text({ executed: true, mode, namespaces, entriesDeleted: deleted, invalidatedAt: now, warnings }); + } +); + +const transport = new StdioServerTransport(); +await server.connect(transport); diff --git a/mcp/test/hashtag-probe.mjs b/mcp/test/hashtag-probe.mjs new file mode 100644 index 0000000..ba6eb78 --- /dev/null +++ b/mcp/test/hashtag-probe.mjs @@ -0,0 +1,18 @@ +import { spawn } from "node:child_process"; +const proc = spawn("node", ["dist/server.js"], { + stdio: ["pipe", "pipe", "inherit"], + env: { ...process.env, REDIS_URL: "redis://127.0.0.1:6390", DEPLOYMENT_VERSION: "ns1", HASH_TAG: "true" }, +}); +const send = (o) => proc.stdin.write(JSON.stringify(o) + "\n"); +let buf = ""; const res = []; +proc.stdout.on("data", (d) => { buf += d; let i; while ((i = buf.indexOf("\n")) >= 0) { const l = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (l) try { res.push(JSON.parse(l)); } catch {} } }); +send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "t", version: "0" } } }); +setTimeout(() => send({ jsonrpc: "2.0", method: "notifications/initialized" }), 150); +setTimeout(() => send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "tag_state", arguments: { tag: "mytag" } } }), 350); +setTimeout(() => { + proc.kill(); + const st = JSON.parse(res.find((r) => r.id === 2)?.result?.content?.[0]?.text ?? "{}"); + const ok = st.useCache?.invalidatedAt === 1785700000000 && String(st.useCache?.markerKey).includes("{ns1}"); + console.log(ok ? "HASHTAG PROBE OK" : "HASHTAG PROBE FAIL", "| marker:", st.useCache?.markerKey, "| ts:", st.useCache?.invalidatedAt); + process.exit(ok ? 0 : 1); +}, 1200); diff --git a/mcp/test/live.mjs b/mcp/test/live.mjs new file mode 100644 index 0000000..2161a57 --- /dev/null +++ b/mcp/test/live.mjs @@ -0,0 +1,27 @@ +/* Live tool calls against real Redis. */ +import { spawn } from "node:child_process"; +const proc = spawn("node", ["dist/server.js"], { + stdio: ["pipe", "pipe", "inherit"], + env: { ...process.env, REDIS_URL: "redis://127.0.0.1:6390" }, +}); +const send = (o) => proc.stdin.write(JSON.stringify(o) + "\n"); +let buf = ""; const res = []; +proc.stdout.on("data", (d) => { + buf += d; + let i; while ((i = buf.indexOf("\n")) >= 0) { const l = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (l) res.push(JSON.parse(l)); } +}); +send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "live", version: "0" } } }); +setTimeout(() => send({ jsonrpc: "2.0", method: "notifications/initialized" }), 100); +setTimeout(() => send({ jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "cache_health", arguments: {} } }), 250); +setTimeout(() => send({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "tag_state", arguments: { tag: "probe-tag" } } }), 500); +setTimeout(() => send({ jsonrpc: "2.0", id: 4, method: "tools/call", params: { name: "invalidate_tag", arguments: { tag: "probe-tag", mode: "hard" } } }), 750); +setTimeout(() => { + proc.kill(); + const get = (id) => JSON.parse(res.find((r) => r.id === id)?.result?.content?.[0]?.text ?? "{}"); + const health = get(2), state = get(3), dry = get(4); + const ok = typeof health.pingMs === "number" + && state.isr?.state?.stale === 1785500000000 + && dry.dryRun === true && Array.isArray(dry.plan); + console.log(ok ? "MCP LIVE OK" : "MCP LIVE FAIL", "| ping:", health.pingMs + "ms", "| tag stale:", state.isr?.state?.stale, "| dry-run plan lines:", dry.plan?.length); + process.exit(ok ? 0 : 1); +}, 1500); diff --git a/mcp/test/smoke.mjs b/mcp/test/smoke.mjs new file mode 100644 index 0000000..043bccf --- /dev/null +++ b/mcp/test/smoke.mjs @@ -0,0 +1,31 @@ +/* Stdio smoke: initialize + tools/list must return all 7 tools. */ +import { spawn } from "node:child_process"; + +const proc = spawn("node", ["dist/server.js"], { stdio: ["pipe", "pipe", "inherit"] }); +const send = (obj) => proc.stdin.write(JSON.stringify(obj) + "\n"); + +let buf = ""; +const responses = []; +proc.stdout.on("data", (d) => { + buf += d.toString(); + let idx; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (line) responses.push(JSON.parse(line)); + } +}); + +send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "smoke", version: "0" } } }); +setTimeout(() => send({ jsonrpc: "2.0", method: "notifications/initialized" }), 150); +setTimeout(() => send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), 300); + +setTimeout(() => { + proc.kill(); + const list = responses.find((r) => r.id === 2); + const names = (list?.result?.tools ?? []).map((t) => t.name).sort(); + const expected = ["cache_health", "cache_inspect", "cache_search", "explain_key", "invalidate_tag", "simulate_swr", "tag_state"]; + const ok = JSON.stringify(names) === JSON.stringify(expected); + console.log(ok ? "MCP SMOKE OK — tools:" : "MCP SMOKE FAIL — tools:", names.join(", ")); + process.exit(ok ? 0 : 1); +}, 1200); diff --git a/mcp/tsconfig.json b/mcp/tsconfig.json new file mode 100644 index 0000000..319d822 --- /dev/null +++ b/mcp/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/mcp/tsup.config.ts b/mcp/tsup.config.ts new file mode 100644 index 0000000..94a86a9 --- /dev/null +++ b/mcp/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { server: "src/server.ts" }, + format: ["esm"], + clean: true, + banner: { js: "#!/usr/bin/env node" }, +}); diff --git a/package.json b/package.json index 7fabfd8..919c717 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@leejpsd/nextjs-cache-handler", "version": "0.3.4", - "description": "Redis cache handler for Next.js 15/16 — the only one shipping both cacheHandler (ISR) and cacheHandlers ('use cache') with built-in compression, Redis Sentinel, and OpenTelemetry. Production-validated on multi-instance AWS: reconnect backoff, cross-instance tag invalidation, zero-downtime Redis failover.", + "description": "Redis cache handler for Next.js 15/16 \u2014 the only one shipping both cacheHandler (ISR) and cacheHandlers ('use cache') with built-in compression, Redis Sentinel, and OpenTelemetry. Production-validated on multi-instance AWS: reconnect backoff, cross-instance tag invalidation, zero-downtime Redis failover.", "keywords": [ "next", "nextjs", @@ -55,6 +55,9 @@ ], "otel": [ "./dist/otel/index.d.ts" + ], + "seed": [ + "./dist/seed/index.d.ts" ] } }, @@ -119,6 +122,16 @@ "default": "./dist/ops/index.cjs" } }, + "./seed": { + "import": { + "types": "./dist/seed/index.d.ts", + "default": "./dist/seed/index.js" + }, + "require": { + "types": "./dist/seed/index.d.cts", + "default": "./dist/seed/index.cjs" + } + }, "./otel": { "import": { "types": "./dist/otel/index.d.ts", @@ -133,6 +146,7 @@ }, "files": [ "dist/", + "bin/", "skills/", "rules/", "setup-instructions/", @@ -161,7 +175,10 @@ "attw": "attw --pack .", "version:changesets": "changeset version", "publish:changesets": "npm run build && changeset publish", - "prepublishOnly": "npm run verify && npm run publint && npm run attw" + "prepublishOnly": "npm run verify && npm run publint && npm run attw", + "test:cluster": "vitest run --config vitest.cluster.config.ts", + "test:cluster:up": "scripts/cluster-test-env.sh up", + "test:cluster:down": "scripts/cluster-test-env.sh down" }, "peerDependencies": { "next": ">=15.0.0 <17", @@ -203,5 +220,8 @@ }, "publishConfig": { "access": "public" + }, + "bin": { + "nextjs-cache-handler": "./bin/cli.cjs" } } diff --git a/scripts/cluster-test-env.sh b/scripts/cluster-test-env.sh new file mode 100755 index 0000000..1c3696e --- /dev/null +++ b/scripts/cluster-test-env.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Local 3-master Redis Cluster for integration tests (no docker needed). +# Usage: scripts/cluster-test-env.sh up|down +set -euo pipefail +PORTS=(7100 7101 7102) +DIR="${TMPDIR:-/tmp}/nch-cluster" + +up() { + mkdir -p "$DIR" + for p in "${PORTS[@]}"; do + redis-server --port "$p" --cluster-enabled yes \ + --cluster-config-file "$DIR/nodes-$p.conf" \ + --daemonize yes --save "" --appendonly no \ + --logfile "$DIR/redis-$p.log" --dir "$DIR" + done + sleep 0.5 + redis-cli --cluster create $(for p in "${PORTS[@]}"; do echo -n "127.0.0.1:$p "; done) \ + --cluster-replicas 0 --cluster-yes > "$DIR/create.log" 2>&1 + # Wait for cluster_state:ok on every node. + for i in $(seq 1 20); do + ok=1 + for p in "${PORTS[@]}"; do + state=$(redis-cli -p "$p" cluster info 2>/dev/null | grep cluster_state | tr -d '\r') + [[ "$state" == "cluster_state:ok" ]] || ok=0 + done + [[ $ok == 1 ]] && break + sleep 0.5 + done + redis-cli -p "${PORTS[0]}" cluster info | grep cluster_state +} + +down() { + for p in "${PORTS[@]}"; do + redis-cli -p "$p" shutdown nosave 2>/dev/null || true + done + rm -rf "$DIR" +} + +case "${1:-}" in + up) up ;; + down) down ;; + *) echo "usage: $0 up|down" >&2; exit 1 ;; +esac diff --git a/skills/nextjs-redis-cache/SKILL.md b/skills/nextjs-redis-cache/SKILL.md index c815801..b286ba6 100644 --- a/skills/nextjs-redis-cache/SKILL.md +++ b/skills/nextjs-redis-cache/SKILL.md @@ -109,12 +109,27 @@ Facts that prevent misdiagnosis: | `abortTimeoutMs` | 1500 | Per-op Redis deadline; timeouts degrade to miss | | `hashTag: true` | off | REQUIRED on Redis Cluster | | `onMetric` | — | Wire `createOtelMetricEmitter()` from `@leejpsd/nextjs-cache-handler/otel` | +| `tagPubSub: true` | off | 0.4+: push-based cross-instance invalidation (~3ms vs seconds); polling stays as safety net; not on Cluster | Reliability built in (0.3+): reconnect with exponential backoff (1s→30s cap), bounded memory fallback, per-op timeouts. A Redis outage degrades to in-memory serving; reconnection is automatic (validated with a live ElastiCache reboot drill: 2550 requests, zero 5xx). +### 0.4+ deployment accelerators + +- **Seed the cache at deploy time** so a fresh deployment's first requests + are HITs instead of a regeneration stampede: + `REDIS_URL=... DEPLOYMENT_VERSION= npx nextjs-cache-handler seed` + (run after `next build`, e.g. a Docker entrypoint step; NX semantics — + never overwrites live entries). +- **`npx nextjs-cache-handler init --yes`** wires everything above + automatically; **`npx nextjs-cache-handler doctor`** is the first command + to run when debugging connectivity or key-layout issues. +- **MCP server** for cache operations from your agent: + `@leejpsd/nextjs-cache-handler-mcp` (cache_health, tag_state, + invalidate_tag dry-run, …). + ## 6. Production checklist - [ ] `DEPLOYMENT_VERSION` injected at runtime (runner stage in Docker) diff --git a/src/cache-components/handler.ts b/src/cache-components/handler.ts index 3ae7854..b02ebc8 100644 --- a/src/cache-components/handler.ts +++ b/src/cache-components/handler.ts @@ -90,6 +90,7 @@ interface HandlerState { | "keyPrefix" | "singleFlight" | "singleFlightLockTtlSec" + | "tagPubSub" > > & { rest: CacheHandlerOptions }; /** Stable instance identifier baked into refresh-lock owner field. Helps @@ -106,6 +107,14 @@ interface HandlerState { memTagExp: Map; /** Local mirror of recent tag invalidations refreshed from Redis. */ localTagTimestamps: Map; + /** tagPubSub subscription state (opt-in acceleration; polling remains). */ + pubsub: { + active: boolean; + disabled: boolean; + attempting: boolean; + lastAttempt: number; + stop?: () => Promise; + }; /** Resolved build namespace, computed once per call. */ resolveNs: () => string; } @@ -132,6 +141,7 @@ function init(opts: CacheHandlerOptions): HandlerState { hashTag: opts.hashTag ?? false, keyPrefix: opts.keyPrefix ?? DEFAULT_KEY_PREFIX, singleFlight: opts.singleFlight ?? false, + tagPubSub: opts.tagPubSub ?? false, singleFlightLockTtlSec: opts.singleFlightLockTtlSec ?? DEFAULT_LOCK_TTL_SEC, rest: opts, @@ -143,6 +153,7 @@ function init(opts: CacheHandlerOptions): HandlerState { memTags: new MemorySetStore(opts.memoryMaxEntries), memTagExp: new Map(), localTagTimestamps: new Map(), + pubsub: { active: false, disabled: false, attempting: false, lastAttempt: 0 }, resolveNs: () => resolveBuildNamespace(opts.buildNamespace), instanceId: process.env.HOSTNAME || @@ -256,6 +267,7 @@ async function getImpl( ): Promise { const start = Date.now(); const useRedis = gateRedis(state); + if (useRedis) void ensureTagSubscription(state); if (!useRedis && state.opts.fallback === "never") { state.emit({ type: "cache.miss", meta: { reason: "no-redis" } }); @@ -357,8 +369,16 @@ async function getImpl( if (freshnessTags.length > 0) { let mostRecent = 0; for (const t of freshnessTags) { - const ts = state.localTagTimestamps.get(t); - if (ts !== undefined && ts > mostRecent) mostRecent = ts; + // memTagExp is folded in deliberately: refreshTags rebuilds + // localTagTimestamps wholesale from scanned markers, and a pub/sub + // message whose PUBLISHER clock lags the local scanStart can be + // rejected by the carry-over while SCAN missed its just-written + // marker — memTagExp retains it (size-pruned, never scan-rebuilt). + const ts = Math.max( + state.localTagTimestamps.get(t) ?? 0, + state.memTagExp.get(t) ?? 0 + ); + if (ts > mostRecent) mostRecent = ts; } if (mostRecent > envelope.timestamp) { // Soft invalidation is stale-while-revalidate per spec §2.3#updateTags @@ -542,6 +562,85 @@ async function luaSetWithTags( ); } +// ─── tagPubSub (opt-in push propagation) ───────────────────────────────────── + +function invalChannel(state: HandlerState): string { + const ns = state.opts.hashTag + ? `{${state.resolveNs()}}` + : state.resolveNs(); + return `${state.opts.keyPrefix}inval:${ns}`; +} + +const SUBSCRIBE_RETRY_MS = 5000; + +/** + * Lazily establish the invalidation subscription. Failures never surface to + * callers: the refreshTags() scan remains the consistency safety net, so a + * missing/broken subscription only costs latency, not correctness. + */ +async function ensureTagSubscription(state: HandlerState): Promise { + if ( + !state.opts.tagPubSub || + state.pubsub.active || + state.pubsub.disabled || + state.pubsub.attempting + ) { + return; + } + const now = Date.now(); + if (now - state.pubsub.lastAttempt < SUBSCRIBE_RETRY_MS) return; + state.pubsub.lastAttempt = now; + state.pubsub.attempting = true; + + try { + const client = await state.conn.getOrConnect(); + if (!client) return; + if (typeof client.subscribe !== "function") { + state.pubsub.disabled = true; + state.logger.warn( + "tagPubSub unavailable on this client (no subscribe support, e.g. Cluster) — falling back to scan-based propagation" + ); + return; + } + const stop = await client.subscribe( + invalChannel(state), + (message) => { + try { + const parsed = JSON.parse(message) as { t?: string[]; ts?: number }; + if (!Array.isArray(parsed.t) || typeof parsed.ts !== "number") return; + for (const tag of parsed.t) { + const prev = state.localTagTimestamps.get(tag) ?? 0; + if (parsed.ts > prev) state.localTagTimestamps.set(tag, parsed.ts); + const prevMem = state.memTagExp.get(tag) ?? 0; + if (parsed.ts > prevMem) state.memTagExp.set(tag, parsed.ts); + } + // #11: keep the mirrors bounded between scans too. + pruneOldestByTimestamp(state.localTagTimestamps, MAX_LOCAL_TAG_ENTRIES); + pruneOldestByTimestamp(state.memTagExp, MAX_LOCAL_TAG_ENTRIES); + } catch { + // Malformed message — ignore; the scan will reconcile. + } + }, + () => { + // Subscriber connection died (failover/restart). Un-latch so the + // 5s retry path re-establishes; the scan covers the gap. + state.pubsub.active = false; + } + ); + state.pubsub.active = true; + state.pubsub.stop = async () => { + state.pubsub.active = false; + await stop(); + }; + } catch (err) { + state.logger.warn("tagPubSub subscribe failed — will retry", { + message: (err as Error).message, + }); + } finally { + state.pubsub.attempting = false; + } +} + // ─── refreshTags() ─────────────────────────────────────────────────────────── async function refreshTagsImpl(state: HandlerState): Promise { @@ -552,6 +651,7 @@ async function refreshTagsImpl(state: HandlerState): Promise { return; } + void ensureTagSubscription(state); try { await withAbortSignal( "cacheHandlers.refreshTags", @@ -775,6 +875,20 @@ async function updateTagsImpl( type: isHardExpire ? "tag.invalidate.hard" : "tag.invalidate.soft", meta: { count: tags.length, backend: "redis" }, }); + if (state.opts.tagPubSub) { + // Best-effort push notification — subscribers converge in ms instead + // of at their next refreshTags() scan. Failures are irrelevant to + // correctness (markers are already written). + try { + const client = await state.conn.getOrConnect(); + await client?.publish?.( + invalChannel(state), + JSON.stringify({ t: tags, ts: now }) + ); + } catch { + /* scan reconciles */ + } + } } catch (err) { if (err instanceof CacheTimeoutError) { state.emit({ type: "redis.timeout", meta: { op: "updateTags" } }); diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..8174b26 --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,465 @@ +/** + * `nextjs-cache-handler` CLI — agent-friendly project setup & diagnostics. + * + * Commands: + * init [--yes] [--dry-run] [--skills] wire the handlers into a Next.js app + * doctor [--url ] connectivity + cache-state diagnostics + * + * Design constraints: + * - Zero dependencies: node builtins only. Redis access reuses the + * package's own client adapters (which lazy-load the host app's + * installed `redis`/`ioredis` peer). + * - Never destructive by default: existing files are left alone unless + * `--yes` is passed; next.config changes are shown as instructions. + * - Output is written for BOTH humans and agents: stable `[ok]/[warn]/ + * [action]` prefixes that are trivial to parse. + */ + +import fs from "node:fs"; +import path from "node:path"; + +import { buildClient } from "../shared/client/index.js"; +import { seedBuildOutput } from "../seed/index.js"; +import type { RedisClientLike } from "../types.js"; + +const OK = "[ok]"; +const WARN = "[warn]"; +const ACTION = "[action]"; +const ERR = "[error]"; + +interface Ctx { + cwd: string; + args: string[]; + flags: Set; + log: (line: string) => void; +} + +// ─── helpers ───────────────────────────────────────────────────────────────── + +function readJson(file: string): Record | null { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as Record; + } catch { + return null; + } +} + +function detectNextMajor(pkg: Record): { + raw: string | null; + major: number | null; + supportsCacheComponents: boolean; +} { + const deps = { + ...(pkg.dependencies as Record | undefined), + ...(pkg.devDependencies as Record | undefined), + }; + const raw = deps["next"] ?? null; + if (!raw) return { raw: null, major: null, supportsCacheComponents: false }; + const m = raw.match(/(\d+)\.(\d+)/); + if (!m) return { raw, major: null, supportsCacheComponents: false }; + const major = Number(m[1]); + const minor = Number(m[2]); + // cacheHandlers exists from 16.1.5; treat 16.1+ ranges as capable and let + // the peer range enforce the floor precisely. + const supportsCacheComponents = major > 16 ? false : major === 16 && minor >= 1; + return { raw, major, supportsCacheComponents }; +} + +function detectClient(pkg: Record): "redis" | "ioredis" | null { + const deps = { + ...(pkg.dependencies as Record | undefined), + ...(pkg.devDependencies as Record | undefined), + }; + if (deps["redis"]) return "redis"; + if (deps["ioredis"]) return "ioredis"; + return null; +} + +function findNextConfig(cwd: string): string | null { + for (const name of ["next.config.ts", "next.config.js", "next.config.mjs", "next.config.cjs"]) { + const p = path.join(cwd, name); + if (fs.existsSync(p)) return p; + } + return null; +} + +const RULES_BEGIN = ""; +const RULES_END = ""; + +function packagedFile(rel: string): string | null { + // dist/cli/index.cjs|js → package root is two levels up. + const root = path.resolve(__dirname, "..", ".."); + const p = path.join(root, rel); + return fs.existsSync(p) ? p : null; +} + +function shimSource(kind: "incremental" | "cache-components", clientType: string): string { + const factory = + kind === "incremental" + ? "createIncrementalCacheHandler" + : "createCacheComponentsHandler"; + const subpath = kind === "incremental" ? "incremental" : "cache-components"; + return `// Generated by \`npx nextjs-cache-handler init\` +const { ${factory} } = require("@leejpsd/nextjs-cache-handler/${subpath}"); + +module.exports = ${factory}({ + client: { type: "${clientType}", url: process.env.REDIS_URL }, + // Per-deploy namespace isolation — set DEPLOYMENT_VERSION in every runtime + // environment (Docker: the runner stage), e.g. the git SHA. + buildNamespace: () => process.env.DEPLOYMENT_VERSION, +}); +`; +} + +// ─── init ──────────────────────────────────────────────────────────────────── + +export async function init(ctx: Ctx): Promise { + const { cwd, flags, log } = ctx; + const apply = flags.has("--yes") && !flags.has("--dry-run"); + + const pkg = readJson(path.join(cwd, "package.json")); + if (!pkg) { + log(`${ERR} no package.json in ${cwd} — run inside your Next.js project`); + return 1; + } + + const next = detectNextMajor(pkg); + if (!next.raw) { + log(`${ERR} "next" is not a dependency here — not a Next.js project`); + return 1; + } + if (next.major !== null && next.major < 15) { + log(`${ERR} next@${next.raw} unsupported — this package needs next >=15.0.0 <17`); + return 1; + } + log(`${OK} next: ${next.raw} → ${next.supportsCacheComponents ? "BOTH handlers (ISR + 'use cache')" : "ISR handler only"}`); + + const deps = pkg.dependencies as Record | undefined; + if (!deps?.["@leejpsd/nextjs-cache-handler"]) { + log(`${ACTION} install the package first: npm i @leejpsd/nextjs-cache-handler`); + } else { + log(`${OK} @leejpsd/nextjs-cache-handler ${deps["@leejpsd/nextjs-cache-handler"]}`); + } + + const client = detectClient(pkg); + if (!client) { + log(`${ACTION} no Redis client installed — run: npm i redis (or ioredis for Cluster/Sentinel)`); + } else { + log(`${OK} redis client: ${client}`); + } + const clientType = client ?? "redis"; + + // 1) Wrapper shims. + const wanted: Array<["incremental" | "cache-components", string]> = [ + ["incremental", "cache-incremental.cjs"], + ]; + if (next.supportsCacheComponents) wanted.push(["cache-components", "cache-components.cjs"]); + + for (const [kind, file] of wanted) { + const target = path.join(cwd, file); + if (fs.existsSync(target)) { + log(`${OK} ${file} already exists — leaving it untouched`); + continue; + } + if (apply) { + fs.writeFileSync(target, shimSource(kind, clientType)); + log(`${OK} wrote ${file}`); + } else { + log(`${ACTION} would create ${file} (re-run with --yes to write)`); + } + } + + // 2) next.config guidance (never auto-edited — users customize this file). + const cfg = findNextConfig(cwd); + const cfgLines = [ + `cacheHandler: require.resolve("./cache-incremental.cjs"),`, + ...(next.supportsCacheComponents + ? [ + `cacheHandlers: { default: require.resolve("./cache-components.cjs") },`, + `cacheComponents: true,`, + ] + : []), + `cacheMaxMemorySize: 0, // required for multi-instance correctness`, + ]; + if (!cfg) { + log(`${WARN} no next.config found — create one with:`); + } else { + const body = fs.readFileSync(cfg, "utf8"); + if (body.includes("cacheHandler")) { + log(`${OK} ${path.basename(cfg)} already references a cacheHandler — verify it points at the shims above`); + } else { + log(`${ACTION} add to ${path.basename(cfg)}:`); + } + if (cfg.endsWith(".mjs")) { + log(`${WARN} ESM config: add import { createRequire } from "module"; const require = createRequire(import.meta.url);`); + } + } + for (const l of cfgLines) log(` ${l}`); + + // 3) Env template. + const envExample = path.join(cwd, ".env.example"); + const envBlock = `\n# @leejpsd/nextjs-cache-handler\nREDIS_URL=redis://127.0.0.1:6379\nDEPLOYMENT_VERSION=dev\n`; + if (fs.existsSync(envExample)) { + const body = fs.readFileSync(envExample, "utf8"); + if (body.includes("REDIS_URL")) { + log(`${OK} .env.example already mentions REDIS_URL`); + } else if (apply) { + fs.appendFileSync(envExample, envBlock); + log(`${OK} appended REDIS_URL / DEPLOYMENT_VERSION to .env.example`); + } else { + log(`${ACTION} would append REDIS_URL / DEPLOYMENT_VERSION to .env.example`); + } + } else { + log(`${ACTION} set REDIS_URL and DEPLOYMENT_VERSION in every runtime environment`); + } + + // 4) Agent rules injection (idempotent via markers). + const rulesSrc = packagedFile("rules/nextjs-cache-rules.md"); + const ruleTargets = ["CLAUDE.md", "AGENTS.md"] + .map((f) => path.join(cwd, f)) + .filter((p) => fs.existsSync(p)); + if (rulesSrc && ruleTargets.length > 0) { + const rules = `\n${RULES_BEGIN}\n${fs.readFileSync(rulesSrc, "utf8")}\n${RULES_END}\n`; + for (const target of ruleTargets) { + const body = fs.readFileSync(target, "utf8"); + if (body.includes(RULES_BEGIN)) { + log(`${OK} ${path.basename(target)} already has the cache rules block`); + } else if (apply) { + fs.appendFileSync(target, rules); + log(`${OK} appended cache rules to ${path.basename(target)}`); + } else { + log(`${ACTION} would append cache rules to ${path.basename(target)}`); + } + } + } else if (ruleTargets.length === 0) { + log(`${WARN} no CLAUDE.md/AGENTS.md found — skip rules injection (create one to guide your agent)`); + } + + // 5) MCP registration — one file turns any MCP-capable agent (Claude + // Code, Cursor, ...) into a cache operator via npx (no install needed). + const mcpPath = path.join(cwd, ".mcp.json"); + if (fs.existsSync(mcpPath)) { + const body = fs.readFileSync(mcpPath, "utf8"); + if (body.includes("nextjs-cache-handler-mcp")) { + log(`${OK} .mcp.json already registers the cache MCP server`); + } else { + log(`${ACTION} .mcp.json exists — add the "nextjs-cache" server manually (see @leejpsd/nextjs-cache-handler-mcp README)`); + } + } else if (apply) { + fs.writeFileSync( + mcpPath, + JSON.stringify( + { + mcpServers: { + "nextjs-cache": { + command: "npx", + args: ["-y", "@leejpsd/nextjs-cache-handler-mcp"], + env: { + REDIS_URL: "redis://127.0.0.1:6379", + DEPLOYMENT_VERSION: "dev", + }, + }, + }, + }, + null, + 2 + ) + "\n" + ); + log(`${OK} wrote .mcp.json — your agent now has cache_health/tag_state/... tools (edit REDIS_URL for real environments)`); + } else { + log(`${ACTION} would create .mcp.json registering @leejpsd/nextjs-cache-handler-mcp`); + } + + // 6) Optional skill install. + if (flags.has("--skills")) { + const skillSrc = packagedFile("skills/nextjs-redis-cache/SKILL.md"); + if (skillSrc) { + const dest = path.join(cwd, ".claude", "skills", "nextjs-redis-cache", "SKILL.md"); + if (apply) { + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(skillSrc, dest); + log(`${OK} installed skill to .claude/skills/nextjs-redis-cache/`); + } else { + log(`${ACTION} would install skill to .claude/skills/nextjs-redis-cache/`); + } + } + } + + log(`${OK} init ${apply ? "complete" : "preview complete — re-run with --yes to apply"}. Verify with: npx nextjs-cache-handler doctor`); + return 0; +} + +// ─── doctor ────────────────────────────────────────────────────────────────── + +export async function doctor(ctx: Ctx): Promise { + const { args, log } = ctx; + const urlIdx = args.indexOf("--url"); + const url = urlIdx >= 0 ? args[urlIdx + 1] : process.env.REDIS_URL; + if (!url) { + log(`${ERR} no Redis URL — pass --url or set REDIS_URL`); + return 1; + } + + const deployment = process.env.DEPLOYMENT_VERSION; + log(deployment ? `${OK} DEPLOYMENT_VERSION=${deployment}` : `${WARN} DEPLOYMENT_VERSION not set — keys will use the "unversioned" namespace`); + + let client: RedisClientLike; + try { + // Prefer node-redis; fall back to ioredis when only that peer is installed. + try { + client = await buildClient({ type: "redis", url }); + } catch (err) { + if (/Cannot find module/.test((err as Error).message)) { + client = await buildClient({ type: "ioredis", url }); + } else { + throw err; + } + } + if (!client.isOpen) await client.connect(); + } catch (err) { + log(`${ERR} connect failed: ${(err as Error).message}`); + log(`${ACTION} check the URL/network, or install a client: npm i redis (or ioredis)`); + return 1; + } + + try { + const t0 = Date.now(); + await client.ping(); + log(`${OK} redis PING ${Date.now() - t0}ms (${url.replace(/\/\/[^@]*@/, "//***@")})`); + + const counts = new Map(); + for await (const chunk of client.scanIterator({ MATCH: "next-*", COUNT: 500 })) { + const keys = Array.isArray(chunk) ? chunk : [chunk]; + for (const k of keys) { + const key = String(k); + const m = key.match(/^(next-cache|next-incremental):([a-z-]+):/); + const bucket = m ? `${m[1]}:${m[2]}` : "other"; + counts.set(bucket, (counts.get(bucket) ?? 0) + 1); + } + } + if (counts.size === 0) { + log(`${WARN} no next-* keys found — cache not yet exercised (or a custom keyPrefix is in use)`); + } else { + for (const [bucket, n] of [...counts.entries()].sort()) { + log(`${OK} ${bucket}* — ${n} keys`); + } + } + + // Round-trip probe in an isolated namespace. + const probeKey = `next-cache:doctor:${Date.now()}`; + await client.set(probeKey, "ok", { EX: 30 }); + const back = await client.get(probeKey); + await client.del(probeKey); + log(back === "ok" ? `${OK} write/read round-trip` : `${ERR} round-trip failed`); + return back === "ok" ? 0 : 1; + } finally { + client.dispose?.(); + } +} + + +// ─── seed ──────────────────────────────────────────────────────────────────── + +export async function seed(ctx: Ctx): Promise { + const { args, flags, log } = ctx; + const urlIdx = args.indexOf("--url"); + const url = urlIdx >= 0 ? args[urlIdx + 1] : process.env.REDIS_URL; + if (!url) { + log(`${ERR} no Redis URL — pass --url or set REDIS_URL`); + return 1; + } + const flagVal = (name: string): string | undefined => { + const i = args.indexOf(name); + return i >= 0 ? args[i + 1] : undefined; + }; + const dir = flagVal("--dir") ?? ".next"; + const keyPrefix = flagVal("--key-prefix"); + const namespace = flagVal("--namespace"); + const compression = flagVal("--compression") as "gzip" | "brotli" | undefined; + const hashTag = flags.has("--hash-tag"); + + const ns = + namespace ?? + process.env.DEPLOYMENT_VERSION ?? + process.env.GIT_HASH ?? + "unversioned"; + if (ns === "unversioned") { + log(`${WARN} no namespace — seeding into "unversioned" (set DEPLOYMENT_VERSION or pass --namespace)`); + } + // Print the exact key shape so a config mismatch with the runtime handler + // (custom keyPrefix / hashTag) is visible instead of silently seeding + // keys the app never reads. + log(`${OK} target keys: ${keyPrefix ?? "next-incremental:"}entry:${hashTag ? `{${ns}}` : ns}:*`); + + const seedOpts = { + dir, + buildNamespace: ns, + ...(keyPrefix ? { keyPrefix } : {}), + ...(compression ? { compression } : {}), + ...(hashTag ? { hashTag: true } : {}), + }; + const report = (summary: Awaited>): number => { + log(`${OK} seeded: ${summary.routes} app routes, ${summary.pages} pages routes, ${summary.fetch} fetch entries`); + if (summary.skippedExisting > 0) + log(`${OK} left ${summary.skippedExisting} newer live entries untouched (NX)`); + if (summary.skippedRouteHandlers > 0) + log(`${OK} ${summary.skippedRouteHandlers} prerendered route handlers not seeded (by design)`); + if (summary.skippedIncomplete > 0) + log(`${WARN} skipped ${summary.skippedIncomplete} routes (incomplete files or PPR resume state)`); + for (const e of summary.errors.slice(0, 5)) log(`${WARN} ${e}`); + return summary.errors.length > 0 ? 1 : 0; + }; + + try { + return report( + await seedBuildOutput({ client: { type: "redis", url }, ...seedOpts }) + ); + } catch (err) { + const msg = (err as Error).message; + if (/Cannot find module 'redis'/.test(msg)) { + // Retry with ioredis when only that peer is installed. + try { + return report( + await seedBuildOutput({ client: { type: "ioredis", url }, ...seedOpts }) + ); + } catch (err2) { + log(`${ERR} ${(err2 as Error).message}`); + return 1; + } + } + log(`${ERR} ${msg}`); + return 1; + } +} + +// ─── entry ─────────────────────────────────────────────────────────────────── + +export async function main(argv: string[] = process.argv.slice(2)): Promise { + const [cmd, ...rest] = argv; + const ctx: Ctx = { + cwd: process.cwd(), + args: rest, + flags: new Set(rest.filter((a) => a.startsWith("--"))), + log: (l) => console.log(l), + }; + switch (cmd) { + case "init": + return init(ctx); + case "doctor": + return doctor(ctx); + case "seed": + return seed(ctx); + default: + console.log(`nextjs-cache-handler + +Commands: + init [--yes] [--dry-run] [--skills] wire handlers into this Next.js app + doctor [--url ] connectivity + cache diagnostics + seed [--dir .next] [--url ] [--namespace ] [--key-prefix

] [--hash-tag] [--compression gzip|brotli] + seed prerendered build output into Redis + +Agent setup instructions: + https://raw.githubusercontent.com/leejpsd/nextjs-cache-handler/main/setup-instructions/setup.md`); + return cmd ? 1 : 0; + } +} diff --git a/src/incremental/handler.ts b/src/incremental/handler.ts index 52ad3e9..6e6b373 100644 --- a/src/incremental/handler.ts +++ b/src/incremental/handler.ts @@ -243,7 +243,14 @@ async function readTagStates( async () => { const client = await state.conn.getOrConnect(); if (!client) return tags.map((t) => state.memTagStates.get(t) ?? null); - const values = await client.mGet(tags.map((t) => tagMetaKey(state, t))); + // Per-key GETs, not MGET: ISR tag keys are deliberately un-namespaced + // (cross-deploy semantics), so on Redis Cluster a multi-tag MGET is a + // guaranteed CROSSSLOT error — which the outer catch would silently + // swallow, disabling cross-instance revalidateTag for multi-tag + // entries. Auto-pipelining makes the fan-out one round trip anyway. + const values = await Promise.all( + tags.map((t) => client.get(tagMetaKey(state, t))) + ); return values.map((v) => { if (!v) return null; try { diff --git a/src/seed/index.ts b/src/seed/index.ts new file mode 100644 index 0000000..1f00b0b --- /dev/null +++ b/src/seed/index.ts @@ -0,0 +1,277 @@ +/** + * Build-output cache seeding. + * + * `next build` prerenders pages and fetch-cache entries into `.next/`, but a + * fresh deployment's Redis is empty — every first request pays a full + * regeneration (and N instances pay it N times). `seedBuildOutput()` walks + * the build output and inserts it into Redis in the exact record format the + * incremental handler reads, using **NX semantics**: an entry that a running + * instance has already written (necessarily newer) is never overwritten. + * + * Run it once per deploy, after `next build` and before (or while) traffic + * shifts — e.g. as a Docker entrypoint step or CI job: + * + * npx nextjs-cache-handler seed # uses REDIS_URL / DEPLOYMENT_VERSION + * npx nextjs-cache-handler seed --dir .next + * + * Seeds: + * - App Router routes (`.next/server/app/.{html,rsc,meta}` + + * PPR `.segments/`) → APP_PAGE records + * - Pages Router routes (`.next/server/pages/.{html,json}`) → PAGES + * - fetch cache (`.next/cache/fetch-cache/`) → FETCH records + */ + +import fs from "node:fs"; +import path from "node:path"; + +import { buildClient } from "../shared/client/index.js"; +import { compressValue } from "../shared/compress.js"; +import { buildKey, resolveBuildNamespace } from "../shared/namespace.js"; +import { serializeCacheRecord } from "../incremental/serialize.js"; +import type { + Logger, + RedisClientConfig, + RedisClientFactory, + RedisClientLike, +} from "../types.js"; + +const ONE_YEAR_SEC = 60 * 60 * 24 * 365; +const MIN_TTL_SEC = 60; +const DEFAULT_KEY_PREFIX = "next-incremental:"; + +export interface SeedOptions { + client: RedisClientFactory | RedisClientConfig; + /** Path to the build output. Default: `./.next`. */ + dir?: string; + keyPrefix?: string; + buildNamespace?: string | (() => string); + compression?: "gzip" | "brotli"; + hashTag?: boolean; + logger?: Pick; +} + +export interface SeedSummary { + /** App Router routes written. */ + routes: number; + /** Pages Router routes written. */ + pages: number; + /** fetch-cache entries written. */ + fetch: number; + /** Entries skipped because a (newer) live entry already existed (NX). */ + skippedExisting: number; + /** Routes skipped because their files were incomplete or PPR-postponed. */ + skippedIncomplete: number; + /** Prerendered route handlers (.body) — never seeded, by design. */ + skippedRouteHandlers: number; + errors: string[]; +} + +interface ManifestRoute { + initialRevalidateSeconds?: number | false; + dataRoute?: string | null; + srcRoute?: string | null; +} + +function clampTtl(revalidate: number | false | undefined): number { + if (revalidate === false || revalidate === undefined) return ONE_YEAR_SEC; + if (typeof revalidate === "number" && revalidate > 0) { + return Math.min(Math.max(Math.ceil(revalidate), MIN_TTL_SEC), ONE_YEAR_SEC); + } + return MIN_TTL_SEC; +} + +function routeToFileBase(route: string): string { + return route === "/" ? "/index" : route; +} + +function readMeta(file: string): { + status?: number; + headers?: Record; + segmentPaths?: string[]; + postponed?: string; +} | null { + try { + return JSON.parse(fs.readFileSync(file, "utf8")) as ReturnType; + } catch { + return null; + } +} + +async function writeRecord( + client: RedisClientLike, + key: string, + value: unknown, + revalidateSec: number | false | undefined, + opts: Required> & { + ns: string; + compression?: "gzip" | "brotli"; + } +): Promise<"written" | "skipped"> { + const record = { + lastModified: Date.now(), + value, + revalidateSec: clampTtl(revalidateSec), + }; + const redisKey = buildKey(`${opts.keyPrefix}entry:`, opts.ns, key, opts.hashTag); + const payload = await compressValue( + serializeCacheRecord(record), + opts.compression + ); + const result = await client.set(redisKey, payload, { + EX: clampTtl(revalidateSec), + NX: true, + }); + // Redis returns null (nil) when NX blocks the write. + return result === null ? "skipped" : "written"; +} + +export async function seedBuildOutput(options: SeedOptions): Promise { + const dir = path.resolve(options.dir ?? ".next"); + const keyPrefix = options.keyPrefix ?? DEFAULT_KEY_PREFIX; + const hashTag = options.hashTag ?? false; + const ns = resolveBuildNamespace(options.buildNamespace); + const warn = options.logger?.warn ?? (() => {}); + const summary: SeedSummary = { + routes: 0, + pages: 0, + fetch: 0, + skippedExisting: 0, + skippedIncomplete: 0, + skippedRouteHandlers: 0, + errors: [], + }; + + const manifestPath = path.join(dir, "prerender-manifest.json"); + if (!fs.existsSync(manifestPath)) { + throw new Error( + `[next-cache] no prerender-manifest.json in ${dir} — run \`next build\` first` + ); + } + const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as { + routes?: Record; + }; + + const client = await buildClient(options.client); + if (!client.isOpen) await client.connect(); + + const writeOpts = { keyPrefix, hashTag, ns, ...(options.compression ? { compression: options.compression } : {}) }; + + try { + // ── 1) Prerendered routes ──────────────────────────────────────────────── + for (const [route, info] of Object.entries(manifest.routes ?? {})) { + try { + const isPages = typeof info.dataRoute === "string" && info.dataRoute.endsWith(".json"); + const base = routeToFileBase(route); + + if (isPages) { + const html = path.join(dir, "server", "pages", `${base.slice(1)}.html`); + const data = path.join(dir, "server", "pages", `${base.slice(1)}.json`); + if (!fs.existsSync(html)) { + summary.skippedIncomplete += 1; + continue; + } + const value = { + kind: "PAGES", + html: fs.readFileSync(html, "utf8"), + pageData: fs.existsSync(data) + ? (JSON.parse(fs.readFileSync(data, "utf8")) as unknown) + : {}, + headers: undefined, + status: undefined, + }; + const r = await writeRecord(client, route, value, info.initialRevalidateSeconds, writeOpts); + if (r === "written") summary.pages += 1; + else summary.skippedExisting += 1; + continue; + } + + // App Router + const htmlPath = path.join(dir, "server", "app", `${base.slice(1)}.html`); + const rscPath = path.join(dir, "server", "app", `${base.slice(1)}.rsc`); + const metaPath = path.join(dir, "server", "app", `${base.slice(1)}.meta`); + if (!fs.existsSync(htmlPath) || !fs.existsSync(metaPath)) { + // Prerendered route handlers emit .body/.meta instead of .html — + // they are intentionally not seeded, and not "incomplete". + if (fs.existsSync(path.join(dir, "server", "app", `${base.slice(1)}.body`))) { + summary.skippedRouteHandlers += 1; + } else { + summary.skippedIncomplete += 1; + } + continue; + } + const meta = readMeta(metaPath) ?? {}; + if (meta.postponed) { + // PPR routes with resume state cannot be faithfully seeded from + // static files (the runtime needs `postponed` to resume dynamic + // holes, and Next intentionally omits the .rsc for them). Serving + // a seeded copy would freeze the unresolved shell as final HTML. + summary.skippedIncomplete += 1; + continue; + } + + let segmentData: Map | undefined; + if (Array.isArray(meta.segmentPaths) && meta.segmentPaths.length > 0) { + const segDir = path.join(dir, "server", "app", `${base.slice(1)}.segments`); + segmentData = new Map(); + for (const segPath of meta.segmentPaths) { + const segFile = path.join(segDir, `${segPath.slice(1)}.segment.rsc`); + if (fs.existsSync(segFile)) { + segmentData.set(segPath, fs.readFileSync(segFile)); + } + } + if (segmentData.size !== meta.segmentPaths.length) { + // Partial segment sets would break PPR resumes — safer to skip. + summary.skippedIncomplete += 1; + continue; + } + } + + const value: Record = { + kind: "APP_PAGE", + html: fs.readFileSync(htmlPath, "utf8"), + rscData: fs.existsSync(rscPath) ? fs.readFileSync(rscPath) : undefined, + headers: meta.headers, + status: meta.status, + }; + if (segmentData) value.segmentData = segmentData; + + const r = await writeRecord(client, route, value, info.initialRevalidateSeconds, writeOpts); + if (r === "written") summary.routes += 1; + else summary.skippedExisting += 1; + } catch (err) { + summary.errors.push(`${route}: ${(err as Error).message}`); + warn("seed: route failed", { route, message: (err as Error).message }); + } + } + + // ── 2) fetch cache ─────────────────────────────────────────────────────── + const fetchDir = path.join(dir, "cache", "fetch-cache"); + if (fs.existsSync(fetchDir)) { + for (const file of fs.readdirSync(fetchDir)) { + if (file.startsWith(".")) continue; + try { + const raw = fs.readFileSync(path.join(fetchDir, file), "utf8"); + const value = JSON.parse(raw) as { kind?: string; revalidate?: number }; + if (value.kind !== "FETCH") continue; + const r = await writeRecord(client, file, value, value.revalidate, writeOpts); + if (r === "written") summary.fetch += 1; + else summary.skippedExisting += 1; + } catch (err) { + summary.errors.push(`fetch:${file}: ${(err as Error).message}`); + } + } + } + } finally { + const c = client as RedisClientLike & { + destroy?: () => unknown; + disconnect?: () => unknown; + }; + try { + (c.dispose ?? c.destroy ?? c.disconnect)?.call(c); + } catch { + /* best-effort */ + } + } + + return summary; +} diff --git a/src/shared/client/adapter-ioredis.ts b/src/shared/client/adapter-ioredis.ts index 1509852..61aad87 100644 --- a/src/shared/client/adapter-ioredis.ts +++ b/src/shared/client/adapter-ioredis.ts @@ -27,10 +27,12 @@ function adapt(client: AnyRedis, isClusterClient = false): RedisClientLike { } }, get: (k) => client.get(k), - set: (k, v, opts) => - opts?.EX !== undefined - ? client.set(k, v, "EX", opts.EX) - : client.set(k, v), + set: (k, v, opts) => { + const args: (string | number)[] = []; + if (opts?.EX !== undefined) args.push("EX", opts.EX); + if (opts?.NX) args.push("NX"); + return (client.set as (...a: unknown[]) => Promise)(k, v, ...args); + }, del: async (keys) => { const arr = Array.isArray(keys) ? keys : [keys]; if (arr.length === 0) return 0; @@ -106,6 +108,47 @@ function adapt(client: AnyRedis, isClusterClient = false): RedisClientLike { client.on(event, listener); return client; }, + publish: (channel, message) => client.publish(channel, message), + ...(isClusterClient + ? {} + : { + subscribe: async ( + channel: string, + onMessage: (message: string) => void, + onDown?: () => void + ) => { + const sub = (client as Redis).duplicate(); + let down = false; + const markDown = () => { + if (down) return; + down = true; + try { + sub.disconnect(); + } catch { + /* already closed */ + } + onDown?.(); + }; + sub.on("error", markDown); + sub.on("end", markDown); + try { + if (sub.status !== "ready" && sub.status !== "connecting") { + await sub.connect(); + } + await sub.subscribe(channel); + } catch (err) { + markDown(); + throw err; + } + sub.on("message", (ch: string, message: string) => { + if (ch === channel) onMessage(message); + }); + return async () => { + down = true; + sub.disconnect(); + }; + }, + }), dispose: () => { client.disconnect(); }, diff --git a/src/shared/client/adapter-redis.ts b/src/shared/client/adapter-redis.ts index f77dc94..f0cbec8 100644 --- a/src/shared/client/adapter-redis.ts +++ b/src/shared/client/adapter-redis.ts @@ -14,7 +14,47 @@ import type { RedisClientConfig, RedisClientLike } from "../../types.js"; import { nodeRequire } from "../node-require.js"; export function adaptRedisV5(client: RedisClientType): RedisClientLike { - return client as unknown as RedisClientLike; + const like = client as unknown as RedisClientLike; + like.dispose = () => { + try { + client.destroy(); + } catch { + /* already closed */ + } + }; + // redis@5 exposes publish natively; subscribe needs a duplicate connection. + like.subscribe = async (channel, onMessage, onDown) => { + const sub = client.duplicate(); + const teardown = () => { + try { + sub.destroy(); + } catch { + /* already closed */ + } + }; + let down = false; + const markDown = () => { + if (down) return; + down = true; + teardown(); + onDown?.(); + }; + sub.on("error", markDown); + // reconnectStrategy is false on duplicates — a dropped socket is final, + // so surface it instead of latching a dead subscription as active. + sub.on("end", markDown); + try { + await sub.connect(); + await sub.subscribe(channel, (message: string) => onMessage(message)); + } catch (err) { + // CRITICAL: without this, every failed SUBSCRIBE (e.g. ACL without + // channel permissions) leaks one connected duplicate per retry. + teardown(); + throw err; + } + return async () => teardown(); + }; + return like; } export function createRedisV5Client( diff --git a/src/types.ts b/src/types.ts index 9cb3368..70e3ef8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -11,7 +11,11 @@ export interface RedisClientLike { isOpen: boolean; connect(): Promise; get(key: string): Promise; - set(key: string, value: string, opts?: { EX?: number }): Promise; + set( + key: string, + value: string, + opts?: { EX?: number; NX?: boolean } + ): Promise; del(keys: string | string[]): Promise; sAdd(key: string, member: string | string[]): Promise; sMembers(key: string): Promise; @@ -32,6 +36,21 @@ export interface RedisClientLike { }): AsyncIterable; ping(): Promise; on(event: "error", listener: (err: Error) => void): unknown; + /** + * Publish a message to a channel. Optional — used by the opt-in + * `tagPubSub` acceleration; absence simply disables it. + */ + publish?(channel: string, message: string): Promise; + /** + * Subscribe to a channel on a DEDICATED duplicate connection (Redis + * protocol requirement). Resolves to an unsubscribe/teardown function. + * Optional — absence disables `tagPubSub` on this client. + */ + subscribe?( + channel: string, + onMessage: (message: string) => void, + onDown?: () => void + ): Promise<() => Promise>; /** * Close the underlying connection without waiting for pending replies. * Called by the connection manager when a dead client is replaced. Optional: @@ -208,6 +227,19 @@ export interface CacheHandlerOptions { */ staleWhileRevalidate?: boolean; + /** + * Push-based cross-instance tag propagation (plural handler only). + * + * When enabled, `updateTags()` publishes invalidations on a namespaced + * channel and every instance keeps a subscription on a dedicated + * connection, updating its local tag mirror within milliseconds instead + * of waiting for the next `refreshTags()` scan. The scan keeps running as + * the consistency safety net, so a dropped subscription degrades to the + * default behavior rather than to staleness. Not available on Cluster + * clients (falls back to polling with a warning). Default: `false`. + */ + tagPubSub?: boolean; + /** * Single-flight refresh lock for the SWR boundary. * diff --git a/tests/cluster/cluster.test.ts b/tests/cluster/cluster.test.ts new file mode 100644 index 0000000..9c4e024 --- /dev/null +++ b/tests/cluster/cluster.test.ts @@ -0,0 +1,132 @@ +/** + * Redis Cluster e2e — runs against a real local 3-master cluster + * (scripts/cluster-test-env.sh up; ports 7100-7102). + * + * What this pins down beyond the single-node integration suite: + * - multi-key Lua scripts (set-with-tags / revalidate-hard) work under + * CROSSSLOT constraints via `hashTag: true` + * - the cluster-aware scanIterator (per-master cursors) feeds refreshTags + * - cross-"instance" (two handler objects) tag propagation on a cluster + * - tagPubSub degrades gracefully (no subscribe on the cluster adapter) + */ +import { describe, expect, it } from "vitest"; + +import { createCacheComponentsHandler } from "../../src/cache-components/index.js"; +import { createIncrementalCacheHandler } from "../../src/incremental/index.js"; +import { + bufferToStream, + readStreamFully, +} from "../../src/cache-components/serialize.js"; +import type { CacheComponentsEntry } from "../../src/types.js"; + +const NODES = [ + { host: "127.0.0.1", port: 7100 }, + { host: "127.0.0.1", port: 7101 }, + { host: "127.0.0.1", port: 7102 }, +]; + +function entry(overrides: Partial = {}): CacheComponentsEntry { + return { + value: bufferToStream(Buffer.from("cluster body")), + tags: ["posts"], + stale: 60, + timestamp: Date.now(), + expire: 3600, + revalidate: 60, + ...overrides, + }; +} + +function plural(ns: string, extra: Record = {}) { + return createCacheComponentsHandler({ + client: { type: "cluster", nodes: NODES }, + hashTag: true, + abortTimeoutMs: 3000, + buildNamespace: ns, + ...extra, + }); +} + +describe("Redis Cluster e2e — cacheHandlers (plural)", () => { + it("set-with-tags Lua + get round-trip under hashTag slotting", async () => { + const ns = `cl-${Math.floor(Math.random() * 1e9)}`; + const h = plural(ns); + await h.set("k1", Promise.resolve(entry({ tags: ["a", "b"] }))); + const got = await h.get("k1", []); + expect(got).toBeDefined(); + expect((await readStreamFully(got!.value)).toString()).toBe("cluster body"); + expect(got!.tags).toEqual(["a", "b"]); + }); + + it("hard updateTags (revalidate-hard Lua) deletes tagged entries", async () => { + const ns = `cl-${Math.floor(Math.random() * 1e9)}`; + const h = plural(ns); + await h.set("k1", Promise.resolve(entry({ tags: ["posts"] }))); + await h.set("k2", Promise.resolve(entry({ tags: ["posts"] }))); + + await h.updateTags(["posts"], { expire: 0 }); + + expect(await h.get("k1", [])).toBeUndefined(); + expect(await h.get("k2", [])).toBeUndefined(); + }); + + it("soft invalidation propagates to a second handler via refreshTags (cluster scanIterator)", async () => { + const ns = `cl-${Math.floor(Math.random() * 1e9)}`; + const a = plural(ns); + const b = plural(ns); + + await a.set("k", Promise.resolve(entry({ tags: ["feed"] }))); + await new Promise((r) => setTimeout(r, 20)); + const before = Date.now(); + await a.updateTags([`feed`]); + + await b.refreshTags(); // scans per-master cursors + const learned = await b.getExpiration(["feed"]); + expect(learned).toBeGreaterThanOrEqual(before - 5); + }); + + it("tagPubSub on a cluster degrades to polling with a single warning", async () => { + const ns = `cl-${Math.floor(Math.random() * 1e9)}`; + const warns: string[] = []; + const h = plural(ns, { + tagPubSub: true, + logger: { debug() {}, info() {}, warn: (m: string) => warns.push(m), error() {} }, + }); + await h.get("warm", []); + await h.get("warm", []); + expect(warns.filter((w) => w.includes("tagPubSub unavailable")).length).toBe(1); + + // And the normal path still works. + await h.set("k", Promise.resolve(entry())); + expect(await h.get("k", [])).toBeDefined(); + }); +}); + +describe("Redis Cluster e2e — cacheHandler (ISR)", () => { + it("round-trip + hard revalidateTag", async () => { + const ns = `cl-${Math.floor(Math.random() * 1e9)}`; + const Handler = createIncrementalCacheHandler({ + client: { type: "cluster", nodes: NODES }, + hashTag: true, + abortTimeoutMs: 3000, + buildNamespace: ns, + }); + const h = new Handler(); + + await h.set( + "/page", + { + kind: "APP_PAGE", + body: Buffer.from("hello-cluster"), + headers: { "x-next-cache-tags": "posts" }, + }, + { kind: "APP_PAGE", cacheControl: { revalidate: 300 } } + ); + const out = await h.get("/page", { kind: "APP_PAGE" }); + expect(out).not.toBeNull(); + + await h.revalidateTag("posts"); // hard + h.resetRequestCache(); + expect(await h.get("/page", { kind: "APP_PAGE" })).toBeNull(); + }); +}); diff --git a/tests/integration/cli-doctor.test.ts b/tests/integration/cli-doctor.test.ts new file mode 100644 index 0000000..9f1a489 --- /dev/null +++ b/tests/integration/cli-doctor.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { doctor } from "../../src/cli/index.js"; + +const REDIS_URL = + process.env.INTEGRATION_REDIS_URL || "redis://127.0.0.1:6390"; + +function ctx(args: string[]) { + const lines: string[] = []; + return { + c: { + cwd: process.cwd(), + args, + flags: new Set(args.filter((a) => a.startsWith("--"))), + log: (l: string) => lines.push(l), + }, + lines, + }; +} + +describe("cli doctor (integration)", () => { + it("reports ping, key buckets, and a write/read round-trip against real Redis", async () => { + const { c, lines } = ctx(["--url", REDIS_URL]); + const code = await doctor(c); + const out = lines.join("\n"); + expect(code).toBe(0); + expect(out).toMatch(/\[ok\] redis PING \d+ms/); + expect(out).toContain("[ok] write/read round-trip"); + }); + + it("fails cleanly with a bad URL", async () => { + const { c, lines } = ctx(["--url", "redis://127.0.0.1:1"]); + const code = await doctor(c); + expect(code).toBe(1); + expect(lines.join("\n")).toContain("[error]"); + }); +}); diff --git a/tests/integration/tag-pubsub.test.ts b/tests/integration/tag-pubsub.test.ts new file mode 100644 index 0000000..b0673f3 --- /dev/null +++ b/tests/integration/tag-pubsub.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { createCacheComponentsHandler } from "../../src/cache-components/index.js"; + +const REDIS_URL = + process.env.INTEGRATION_REDIS_URL || "redis://127.0.0.1:6390"; + +const cleanups: Array<() => Promise | void> = []; +afterEach(async () => { + while (cleanups.length) await cleanups.pop()!(); +}); + +function makeHandler(type: "redis" | "ioredis", ns: string) { + return createCacheComponentsHandler({ + client: { type, url: REDIS_URL }, + abortTimeoutMs: 1500, + tagPubSub: true, + buildNamespace: ns, + }); +} + +for (const type of ["redis", "ioredis"] as const) { + describe(`tagPubSub over real Redis (${type})`, () => { + it("propagates an invalidation to a second instance in well under a second", async () => { + const ns = `pubsub-${type}-${Math.floor(Math.random() * 1e9)}`; + const a = makeHandler(type, ns); + const b = makeHandler(type, ns); + + // Lazy subscription needs one gated op; give the SUBSCRIBE a moment. + await b.get("warm", []); + await new Promise((r) => setTimeout(r, 300)); + + const before = Date.now(); + await a.updateTags([`t-${ns}`]); // soft — publishes + + // B must learn the timestamp WITHOUT ever calling refreshTags(). + let learned = 0; + const deadline = Date.now() + 3000; + while (Date.now() < deadline) { + learned = await b.getExpiration([`t-${ns}`]); + if (learned >= before) break; + await new Promise((r) => setTimeout(r, 20)); + } + const latency = Date.now() - before; + expect(learned).toBeGreaterThanOrEqual(before); + expect(latency).toBeLessThan(1000); + + console.log(`[tagPubSub:${type}] cross-instance propagation ${latency}ms`); + }); + }); +} diff --git a/tests/unit/_mock-client.ts b/tests/unit/_mock-client.ts index 7b7d6d1..3e42248 100644 --- a/tests/unit/_mock-client.ts +++ b/tests/unit/_mock-client.ts @@ -54,8 +54,13 @@ export class MockRedisClient implements RedisClientLike { return this.kv.get(key) ?? null; } - async set(key: string, value: string, _opts?: { EX?: number }): Promise { + async set( + key: string, + value: string, + opts?: { EX?: number; NX?: boolean } + ): Promise { await this.tick(); + if (opts?.NX && this.kv.has(key)) return null; // Redis returns nil when NX blocks this.kv.set(key, value); return "OK"; } @@ -152,6 +157,28 @@ export class MockRedisClient implements RedisClientLike { return this; } + private readonly subscribers = new Map void>>(); + + async publish(channel: string, message: string): Promise { + await this.tick(); + const subs = this.subscribers.get(channel) ?? []; + for (const cb of subs) cb(message); + return subs.length; + } + + async subscribe( + channel: string, + onMessage: (message: string) => void + ): Promise<() => Promise> { + const list = this.subscribers.get(channel) ?? []; + list.push(onMessage); + this.subscribers.set(channel, list); + return async () => { + const cur = this.subscribers.get(channel) ?? []; + this.subscribers.set(channel, cur.filter((cb) => cb !== onMessage)); + }; + } + // ─── Test-only helpers ──────────────────────────────────────────────────── fail(err: Error): void { diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts new file mode 100644 index 0000000..e9e42ba --- /dev/null +++ b/tests/unit/cli.test.ts @@ -0,0 +1,144 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { init } from "../../src/cli/index.js"; + +let dir: string; +let lines: string[]; + +function ctx(flags: string[] = []) { + return { + cwd: dir, + args: flags, + flags: new Set(flags), + log: (l: string) => lines.push(l), + }; +} + +function writePkg(nextVersion: string, extraDeps: Record = {}) { + fs.writeFileSync( + path.join(dir, "package.json"), + JSON.stringify({ + name: "app", + dependencies: { + next: nextVersion, + "@leejpsd/nextjs-cache-handler": "^0.3.3", + ...extraDeps, + }, + }) + ); +} + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "nch-cli-")); + lines = []; +}); +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe("cli init — detection", () => { + it("errors outside a package", async () => { + expect(await init(ctx())).toBe(1); + expect(lines.join("\n")).toContain("no package.json"); + }); + + it("errors on next < 15", async () => { + writePkg("^14.2.0"); + expect(await init(ctx())).toBe(1); + expect(lines.join("\n")).toContain("unsupported"); + }); + + it("next 15 → ISR handler only", async () => { + writePkg("^15.3.0", { redis: "^5.0.0" }); + expect(await init(ctx())).toBe(0); + const out = lines.join("\n"); + expect(out).toContain("ISR handler only"); + expect(out).toContain("cache-incremental.cjs"); + expect(out).not.toContain("cache-components.cjs"); + }); + + it("next 16.1+ → both handlers", async () => { + writePkg("^16.1.5", { redis: "^5.0.0" }); + expect(await init(ctx())).toBe(0); + const out = lines.join("\n"); + expect(out).toContain("BOTH handlers"); + expect(out).toContain("cache-components.cjs"); + }); + + it("suggests installing a client when none present", async () => { + writePkg("^16.1.5"); + await init(ctx()); + expect(lines.join("\n")).toContain("npm i redis"); + }); +}); + +describe("cli init — generation (--yes)", () => { + it("writes shims, respects existing files on re-run", async () => { + writePkg("^16.1.5", { ioredis: "^5.0.0" }); + expect(await init(ctx(["--yes"]))).toBe(0); + + const inc = fs.readFileSync(path.join(dir, "cache-incremental.cjs"), "utf8"); + const cc = fs.readFileSync(path.join(dir, "cache-components.cjs"), "utf8"); + expect(inc).toContain("createIncrementalCacheHandler"); + expect(inc).toContain('type: "ioredis"'); + expect(cc).toContain("createCacheComponentsHandler"); + + fs.writeFileSync(path.join(dir, "cache-incremental.cjs"), "// customized"); + lines = []; + await init(ctx(["--yes"])); + expect(fs.readFileSync(path.join(dir, "cache-incremental.cjs"), "utf8")).toBe("// customized"); + expect(lines.join("\n")).toContain("leaving it untouched"); + }); + + it("does not write in preview mode", async () => { + writePkg("^16.1.5", { redis: "^5.0.0" }); + await init(ctx()); + expect(fs.existsSync(path.join(dir, "cache-incremental.cjs"))).toBe(false); + }); + + it("appends env vars to .env.example once", async () => { + writePkg("^15.1.0", { redis: "^5.0.0" }); + fs.writeFileSync(path.join(dir, ".env.example"), "FOO=bar\n"); + await init(ctx(["--yes"])); + await init(ctx(["--yes"])); + const env = fs.readFileSync(path.join(dir, ".env.example"), "utf8"); + expect(env.match(/REDIS_URL/g)?.length).toBe(1); + expect(env).toContain("DEPLOYMENT_VERSION"); + }); + + it("injects the rules block into CLAUDE.md idempotently", async () => { + writePkg("^16.1.5", { redis: "^5.0.0" }); + fs.writeFileSync(path.join(dir, "CLAUDE.md"), "# my project\n"); + await init(ctx(["--yes"])); + await init(ctx(["--yes"])); + const claude = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8"); + expect(claude.match(/nextjs-cache-handler:rules:begin/g)?.length).toBe(1); + expect(claude).toContain("revalidateTag"); + }); + + it("creates .mcp.json registering the MCP server, and never clobbers an existing one", async () => { + writePkg("^16.1.5", { redis: "^5.0.0" }); + await init(ctx(["--yes"])); + const mcp = JSON.parse(fs.readFileSync(path.join(dir, ".mcp.json"), "utf8")); + expect(mcp.mcpServers["nextjs-cache"].args).toContain("@leejpsd/nextjs-cache-handler-mcp"); + + fs.writeFileSync(path.join(dir, ".mcp.json"), '{"mcpServers":{"other":{}}}'); + lines = []; + await init(ctx(["--yes"])); + expect(fs.readFileSync(path.join(dir, ".mcp.json"), "utf8")).toBe('{"mcpServers":{"other":{}}}'); + expect(lines.join("\n")).toContain("add the \"nextjs-cache\" server manually"); + }); + + it("never edits next.config — guidance only", async () => { + writePkg("^16.1.5", { redis: "^5.0.0" }); + const cfg = 'module.exports = { reactStrictMode: true };\n'; + fs.writeFileSync(path.join(dir, "next.config.js"), cfg); + await init(ctx(["--yes"])); + expect(fs.readFileSync(path.join(dir, "next.config.js"), "utf8")).toBe(cfg); + expect(lines.join("\n")).toContain("cacheMaxMemorySize: 0"); + }); +}); diff --git a/tests/unit/seed.test.ts b/tests/unit/seed.test.ts new file mode 100644 index 0000000..a08f615 --- /dev/null +++ b/tests/unit/seed.test.ts @@ -0,0 +1,210 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { seedBuildOutput } from "../../src/seed/index.js"; +import { createIncrementalCacheHandler } from "../../src/incremental/index.js"; +import { deserializeCacheRecord } from "../../src/incremental/serialize.js"; + +import { MockRedisClient } from "./_mock-client.js"; + +let dir: string; + +function fixture() { + const app = path.join(dir, "server", "app"); + fs.mkdirSync(path.join(app, "blog.segments", "blog"), { recursive: true }); + fs.mkdirSync(path.join(dir, "server", "pages"), { recursive: true }); + fs.mkdirSync(path.join(dir, "cache", "fetch-cache"), { recursive: true }); + + fs.writeFileSync( + path.join(dir, "prerender-manifest.json"), + JSON.stringify({ + routes: { + "/": { initialRevalidateSeconds: 60, dataRoute: "/index.rsc" }, + "/blog": { initialRevalidateSeconds: 300, dataRoute: "/blog.rsc" }, + "/legacy": { initialRevalidateSeconds: 120, dataRoute: "/legacy.json" }, + "/broken": { initialRevalidateSeconds: 60, dataRoute: "/broken.rsc" }, + }, + }) + ); + + // App route "/" — plain (no segments) + fs.writeFileSync(path.join(app, "index.html"), "home"); + fs.writeFileSync(path.join(app, "index.rsc"), "RSC-home"); + fs.writeFileSync( + path.join(app, "index.meta"), + JSON.stringify({ status: 200, headers: { "x-next-cache-tags": "home" } }) + ); + + // App route "/blog" — PPR segments + fs.writeFileSync(path.join(app, "blog.html"), "blog"); + fs.writeFileSync(path.join(app, "blog.rsc"), "RSC-blog"); + fs.writeFileSync( + path.join(app, "blog.meta"), + JSON.stringify({ + status: 200, + headers: { "x-next-cache-tags": "posts" }, + segmentPaths: ["/_index", "/blog/__PAGE__"], + }) + ); + fs.writeFileSync(path.join(app, "blog.segments", "_index.segment.rsc"), "seg-index"); + fs.writeFileSync( + path.join(app, "blog.segments", "blog", "__PAGE__.segment.rsc"), + "seg-page" + ); + + // Pages route + fs.writeFileSync(path.join(dir, "server", "pages", "legacy.html"), "legacy"); + fs.writeFileSync( + path.join(dir, "server", "pages", "legacy.json"), + JSON.stringify({ pageProps: { n: 1 } }) + ); + + // fetch cache + fs.writeFileSync( + path.join(dir, "cache", "fetch-cache", "abc123"), + JSON.stringify({ + kind: "FETCH", + data: { headers: {}, body: "ZmV0Y2g=", status: 200, url: "https://x" }, + revalidate: 900, + tags: ["feed"], + }) + ); + // "/broken" has manifest entry but no files → skippedIncomplete +} + +beforeEach(() => { + vi.useFakeTimers({ now: 1_700_000_000_000 }); + dir = fs.mkdtempSync(path.join(os.tmpdir(), "nch-seed-")); + fixture(); +}); +afterEach(() => { + vi.useRealTimers(); + fs.rmSync(dir, { recursive: true, force: true }); +}); + +function mock() { + const client = new MockRedisClient(); + client.isOpen = true; + return client; +} + +describe("seedBuildOutput", () => { + it("seeds app routes, pages routes, and fetch entries with correct keys", async () => { + const client = mock(); + const summary = await seedBuildOutput({ + client: () => client, + dir, + buildNamespace: "deploy-1", + }); + + expect(summary).toMatchObject({ + routes: 2, + pages: 1, + fetch: 1, + skippedExisting: 0, + skippedIncomplete: 1, // "/broken" + errors: [], + }); + expect(client.kv.has("next-incremental:entry:deploy-1:/")).toBe(true); + expect(client.kv.has("next-incremental:entry:deploy-1:/blog")).toBe(true); + expect(client.kv.has("next-incremental:entry:deploy-1:/legacy")).toBe(true); + expect(client.kv.has("next-incremental:entry:deploy-1:abc123")).toBe(true); + }); + + it("writes records the incremental handler can actually serve", async () => { + const client = mock(); + await seedBuildOutput({ client: () => client, dir, buildNamespace: "deploy-1" }); + + const Handler = createIncrementalCacheHandler({ + client: () => client, + abortTimeoutMs: 100, + buildNamespace: "deploy-1", + }); + const h = new Handler(); + const out = await h.get("/", { kind: "APP_PAGE" }); + expect(out).not.toBeNull(); + const value = out!.value as { kind: string; html: string; status: number }; + expect(value.kind).toBe("APP_PAGE"); + expect(value.html).toContain("home"); + expect(out!.tags).toContain("home"); + }); + + it("round-trips PPR segmentData as a Map of Buffers", async () => { + const client = mock(); + await seedBuildOutput({ client: () => client, dir, buildNamespace: "d" }); + + const raw = client.kv.get("next-incremental:entry:d:/blog")!; + const rec = deserializeCacheRecord<{ + value: { segmentData?: Map }; + revalidateSec?: number; + }>(raw)!; + expect(rec.revalidateSec).toBe(300); + const seg = rec.value.segmentData!; + expect(seg).toBeInstanceOf(Map); + expect(seg.get("/_index")?.toString()).toBe("seg-index"); + expect(seg.get("/blog/__PAGE__")?.toString()).toBe("seg-page"); + }); + + it("NX: never overwrites an existing (newer) live entry", async () => { + const client = mock(); + client.kv.set("next-incremental:entry:d:/", "live-entry"); + const summary = await seedBuildOutput({ client: () => client, dir, buildNamespace: "d" }); + expect(client.kv.get("next-incremental:entry:d:/")).toBe("live-entry"); + expect(summary.skippedExisting).toBe(1); + expect(summary.routes).toBe(1); // "/blog" still seeded + }); + + it("skips a PPR route whose segment files are incomplete", async () => { + fs.rmSync(path.join(dir, "server", "app", "blog.segments", "blog", "__PAGE__.segment.rsc")); + const client = mock(); + const summary = await seedBuildOutput({ client: () => client, dir, buildNamespace: "d" }); + expect(client.kv.has("next-incremental:entry:d:/blog")).toBe(false); + expect(summary.skippedIncomplete).toBe(2); // /blog + /broken + }); + + it("BLOCKER regression: skips PPR routes carrying resume state (postponed)", async () => { + // A seeded copy without `postponed` would freeze the unresolved shell + // as the final page — such routes must be skipped, not seeded. + const app = path.join(dir, "server", "app"); + fs.writeFileSync(path.join(app, "ppr.html"), "shell"); + fs.writeFileSync( + path.join(app, "ppr.meta"), + JSON.stringify({ status: 200, headers: {}, postponed: "RESUME-STATE" }) + ); + const manifest = JSON.parse( + fs.readFileSync(path.join(dir, "prerender-manifest.json"), "utf8") + ); + manifest.routes["/ppr"] = { initialRevalidateSeconds: 60, dataRoute: "/ppr.rsc" }; + fs.writeFileSync(path.join(dir, "prerender-manifest.json"), JSON.stringify(manifest)); + + const client = mock(); + const summary = await seedBuildOutput({ client: () => client, dir, buildNamespace: "d" }); + expect(client.kv.has("next-incremental:entry:d:/ppr")).toBe(false); + expect(summary.skippedIncomplete).toBe(2); // /ppr + /broken + }); + + it("prerendered route handlers (.body) are counted separately, not as incomplete", async () => { + const app = path.join(dir, "server", "app"); + fs.writeFileSync(path.join(app, "api-route.body"), "payload"); + fs.writeFileSync(path.join(app, "api-route.meta"), JSON.stringify({ status: 200 })); + const manifest = JSON.parse( + fs.readFileSync(path.join(dir, "prerender-manifest.json"), "utf8") + ); + manifest.routes["/api-route"] = { initialRevalidateSeconds: 60, dataRoute: null }; + fs.writeFileSync(path.join(dir, "prerender-manifest.json"), JSON.stringify(manifest)); + + const client = mock(); + const summary = await seedBuildOutput({ client: () => client, dir, buildNamespace: "d" }); + expect(summary.skippedRouteHandlers).toBe(1); + expect(summary.skippedIncomplete).toBe(1); // only /broken + }); + + it("throws a clear error without a build", async () => { + await expect( + seedBuildOutput({ client: () => mock(), dir: path.join(dir, "nope") }) + ).rejects.toThrow(/next build/); + }); +}); diff --git a/tests/unit/tag-pubsub.test.ts b/tests/unit/tag-pubsub.test.ts new file mode 100644 index 0000000..e966e84 --- /dev/null +++ b/tests/unit/tag-pubsub.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createCacheComponentsHandler } from "../../src/cache-components/index.js"; +import { + bufferToStream, +} from "../../src/cache-components/serialize.js"; +import type { CacheComponentsEntry, MetricEvent } from "../../src/types.js"; + +import { MockRedisClient } from "./_mock-client.js"; + +const T0 = 1_700_000_000_000; + +function entry(overrides: Partial = {}): CacheComponentsEntry { + return { + value: bufferToStream(Buffer.from("body")), + tags: ["posts"], + stale: 60, + timestamp: T0, + expire: 3600, + revalidate: 60, + ...overrides, + }; +} + +beforeEach(() => { + vi.useFakeTimers({ now: T0 }); + delete process.env.NEXT_PHASE; +}); +afterEach(() => { + vi.useRealTimers(); +}); + +function pair(sharedClient: MockRedisClient) { + const events: MetricEvent[] = []; + const a = createCacheComponentsHandler({ + client: () => sharedClient, + abortTimeoutMs: 100, + tagPubSub: true, + }); + const b = createCacheComponentsHandler({ + client: () => sharedClient, + abortTimeoutMs: 100, + tagPubSub: true, + onMetric: (e) => events.push(e), + }); + return { a, b, events }; +} + +describe("cacheHandlers — tagPubSub push propagation", () => { + it("instance B observes A's invalidation WITHOUT calling refreshTags", async () => { + const client = new MockRedisClient(); + client.isOpen = true; + const { a, b } = pair(client); + + // Establish B's subscription (lazy — first redis-gated op). + await b.get("warm-up", []); + await a.get("warm-up", []); // A subscribes too (publisher doesn't need it) + + await a.set("k", Promise.resolve(entry({ tags: ["posts"] }))); + vi.setSystemTime(new Date(T0 + 10)); + await a.updateTags(["posts"]); // soft — publishes on the inval channel + + // NO b.refreshTags() here — the push alone must inform B. + const got = await b.get("k", []); + expect(got).toBeDefined(); // soft = SWR serve... + expect(await b.getExpiration(["posts"])).toBe(T0 + 10); // ...and B knows the timestamp + }); + + it("push updates never move a tag timestamp backwards", async () => { + const client = new MockRedisClient(); + client.isOpen = true; + const { a, b } = pair(client); + await b.get("warm-up", []); + + vi.setSystemTime(new Date(T0 + 100)); + await a.updateTags(["posts"]); + vi.setSystemTime(new Date(T0 + 50)); // out-of-order older publish + await a.updateTags(["posts"]); + + expect(await b.getExpiration(["posts"])).toBe(T0 + 100); + }); + + it("clients without subscribe support disable pubsub once and keep working", async () => { + const client = new MockRedisClient(); + client.isOpen = true; + // Strip subscribe to emulate a Cluster client. + (client as unknown as { subscribe?: unknown }).subscribe = undefined; + const warns: string[] = []; + const handler = createCacheComponentsHandler({ + client: () => client, + abortTimeoutMs: 100, + tagPubSub: true, + logger: { + debug() {}, info() {}, + warn: (m) => warns.push(m), + error() {}, + }, + }); + + await handler.get("k", []); + await handler.get("k", []); + expect(warns.filter((w) => w.includes("tagPubSub unavailable")).length).toBe(1); + + // Polling path still works end to end. + await handler.set("k", Promise.resolve(entry())); + expect(await handler.get("k", [])).toBeDefined(); + }); + + it("BLOCKER regression: a failing subscribe is torn down and retried later (no latch)", async () => { + const client = new MockRedisClient(); + client.isOpen = true; + let calls = 0; + (client as unknown as { subscribe: unknown }).subscribe = async () => { + calls += 1; + throw new Error("NOPERM subscribe"); + }; + const handler = createCacheComponentsHandler({ + client: () => client, + abortTimeoutMs: 100, + tagPubSub: true, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + }); + + await handler.get("k", []); // attempt #1 fails + await handler.get("k", []); // within 5s cooldown → no second attempt + expect(calls).toBe(1); + + vi.setSystemTime(new Date(T0 + 6000)); // past SUBSCRIBE_RETRY_MS + await handler.get("k", []); + expect(calls).toBe(2); // retried, not permanently disabled or latched + }); + + it("a dropped subscription un-latches active so retry can re-establish", async () => { + const client = new MockRedisClient(); + client.isOpen = true; + let downCb: (() => void) | undefined; + let subs = 0; + (client as unknown as { subscribe: unknown }).subscribe = async ( + _ch: string, + _cb: (m: string) => void, + onDown?: () => void + ) => { + subs += 1; + downCb = onDown; + return async () => {}; + }; + const handler = createCacheComponentsHandler({ + client: () => client, + abortTimeoutMs: 100, + tagPubSub: true, + }); + await handler.get("k", []); + expect(subs).toBe(1); + + downCb?.(); // connection dies + vi.setSystemTime(new Date(T0 + 6000)); + await handler.get("k", []); + expect(subs).toBe(2); // re-established + }); + + it("default (tagPubSub off) never subscribes", async () => { + const client = new MockRedisClient(); + client.isOpen = true; + const spy = vi.spyOn(client, "subscribe"); + const handler = createCacheComponentsHandler({ + client: () => client, + abortTimeoutMs: 100, + }); + await handler.get("k", []); + expect(spy).not.toHaveBeenCalled(); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts index 4087804..faf3082 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ "shared/client/adapter-ioredis": "src/shared/client/adapter-ioredis.ts", "ops/index": "src/ops/index.ts", "otel/index": "src/otel/index.ts", + "cli/index": "src/cli/index.ts", + "seed/index": "src/seed/index.ts", }, format: ["esm", "cjs"], // Inject createRequire-based shims so the runtime `require()` calls in the diff --git a/vitest.cluster.config.ts b/vitest.cluster.config.ts new file mode 100644 index 0000000..6b50f41 --- /dev/null +++ b/vitest.cluster.config.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; + +import { defineConfig } from "vitest/config"; + +// Redis Cluster e2e suite. Requires a local 3-master cluster: +// scripts/cluster-test-env.sh up +// Run with: npm run test:cluster +export default defineConfig({ + plugins: [ + { + name: "lua-as-string", + enforce: "pre", + load(id: string) { + if (id.endsWith(".lua")) { + const body = readFileSync(id, "utf8"); + return `export default ${JSON.stringify(body)};`; + } + return null; + }, + }, + ], + test: { + globals: true, + environment: "node", + include: ["tests/cluster/**/*.test.ts"], + testTimeout: 30_000, + hookTimeout: 30_000, + pool: "threads", + fileParallelism: false, + }, +});