mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-09-14 20:00:35 +08:00
feat(registry): add health monitoring (#11616)
* feat(registry): add health monitoring * fix(registry): cache health directory route * fix(registry): harden health state transitions
This commit is contained in:
@@ -0,0 +1,75 @@
|
|||||||
|
name: Monitor Registries
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "17 * * * *"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
mode:
|
||||||
|
description: Registry checks to run
|
||||||
|
required: true
|
||||||
|
default: auto
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- auto
|
||||||
|
- hourly
|
||||||
|
- daily
|
||||||
|
- weekly
|
||||||
|
- all
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: registry-health-monitor
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
monitor:
|
||||||
|
name: Monitor registry health
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20.19.1
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
name: Install pnpm
|
||||||
|
id: pnpm-install
|
||||||
|
with:
|
||||||
|
version: 10.33.4
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Get pnpm store directory
|
||||||
|
id: pnpm-cache
|
||||||
|
run: echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
name: Setup pnpm cache
|
||||||
|
with:
|
||||||
|
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||||
|
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Download previous health state
|
||||||
|
env:
|
||||||
|
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
|
||||||
|
run: pnpm registry:health:prepare
|
||||||
|
|
||||||
|
- name: Check registries
|
||||||
|
env:
|
||||||
|
MONITOR_MODE: ${{ inputs.mode || 'auto' }}
|
||||||
|
run: pnpm registry:health:check
|
||||||
|
|
||||||
|
- name: Publish health snapshot
|
||||||
|
env:
|
||||||
|
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
|
||||||
|
run: pnpm registry:health:publish
|
||||||
@@ -50,6 +50,8 @@ plans
|
|||||||
# vitest browser mode writes these only on test failure.
|
# vitest browser mode writes these only on test failure.
|
||||||
__screenshots__
|
__screenshots__
|
||||||
.codex-artifacts
|
.codex-artifacts
|
||||||
|
.registry-health
|
||||||
|
.vercel
|
||||||
.tmp*
|
.tmp*
|
||||||
|
|
||||||
CONTEXT.md
|
CONTEXT.md
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import { loadRegistryHealthSnapshot } from "@/lib/registry-health/blob"
|
||||||
|
import type {
|
||||||
|
RegistryHealth,
|
||||||
|
RegistryHealthSnapshot,
|
||||||
|
} from "@/lib/registry-health/schema"
|
||||||
|
import directory from "@/registry/directory.json"
|
||||||
|
|
||||||
|
import { dynamic, GET, revalidate } from "./route"
|
||||||
|
|
||||||
|
vi.mock("@/lib/registry-health/blob", () => ({
|
||||||
|
loadRegistryHealthSnapshot: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const GENERATED_AT = "2026-08-24T12:00:00.000Z"
|
||||||
|
const loadSnapshot = vi.mocked(loadRegistryHealthSnapshot)
|
||||||
|
|
||||||
|
function createHealth(overrides: Partial<RegistryHealth> = {}) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
scoreVersion: 1,
|
||||||
|
status: "healthy",
|
||||||
|
statusReason: {
|
||||||
|
code: "healthy_thresholds",
|
||||||
|
message: "Recent checks are within healthy thresholds",
|
||||||
|
},
|
||||||
|
score: 90,
|
||||||
|
breakdown: {
|
||||||
|
reliability: 40,
|
||||||
|
correctness: 23,
|
||||||
|
installability: 18,
|
||||||
|
hygiene: 9,
|
||||||
|
},
|
||||||
|
availability7d: 0.99,
|
||||||
|
availability30d: 0.98,
|
||||||
|
monitoringLimited: false,
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
checkedAt: GENERATED_AT,
|
||||||
|
lastSuccessfulCheck: GENERATED_AT,
|
||||||
|
hidden: false,
|
||||||
|
...overrides,
|
||||||
|
} satisfies RegistryHealth
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSnapshot(registries: RegistryHealthSnapshot["registries"]) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
scoreVersion: 1,
|
||||||
|
generatedAt: GENERATED_AT,
|
||||||
|
globalMeans: {
|
||||||
|
availability7d: 0.85,
|
||||||
|
availability30d: 0.85,
|
||||||
|
indexSchema: 0.9,
|
||||||
|
itemValidity: 0.9,
|
||||||
|
dryRun: 0.9,
|
||||||
|
},
|
||||||
|
registries,
|
||||||
|
} satisfies RegistryHealthSnapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /r/registries.json", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2026-08-24T13:00:00.000Z"))
|
||||||
|
vi.stubEnv("REGISTRY_HEALTH_ENABLED", "1")
|
||||||
|
vi.stubEnv("BLOB_READ_WRITE_TOKEN", "test-token")
|
||||||
|
loadSnapshot.mockReset()
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.unstubAllEnvs()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses static ISR so requests do not read Blob directly", () => {
|
||||||
|
expect(dynamic).toBe("force-static")
|
||||||
|
expect(revalidate).toBe(300)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("merges fresh health by exact namespace", async () => {
|
||||||
|
const first = directory[0]
|
||||||
|
loadSnapshot.mockResolvedValue(
|
||||||
|
createSnapshot({ [first.name]: createHealth() })
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await GET()
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(payload[0]).toMatchObject({
|
||||||
|
name: first.name,
|
||||||
|
homepage: first.homepage,
|
||||||
|
url: first.url,
|
||||||
|
description: first.description,
|
||||||
|
health: {
|
||||||
|
score: 90,
|
||||||
|
statusReason: {
|
||||||
|
code: "healthy_thresholds",
|
||||||
|
message: "Recent checks are within healthy thresholds",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(loadSnapshot).toHaveBeenCalledWith({
|
||||||
|
token: "test-token",
|
||||||
|
abortSignal: expect.any(AbortSignal),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("omits health for a registry missing from the snapshot", async () => {
|
||||||
|
loadSnapshot.mockResolvedValue(createSnapshot({}))
|
||||||
|
|
||||||
|
const response = await GET()
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
expect(payload[0]).not.toHaveProperty("health")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the original payload when health is disabled", async () => {
|
||||||
|
vi.stubEnv("REGISTRY_HEALTH_ENABLED", "0")
|
||||||
|
|
||||||
|
const response = await GET()
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
expect(loadSnapshot).not.toHaveBeenCalled()
|
||||||
|
expect(payload[0]).toEqual({
|
||||||
|
name: directory[0].name,
|
||||||
|
homepage: directory[0].homepage,
|
||||||
|
url: directory[0].url,
|
||||||
|
description: directory[0].description,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fails open for a stale snapshot", async () => {
|
||||||
|
vi.setSystemTime(new Date("2026-08-25T00:00:00.000Z"))
|
||||||
|
loadSnapshot.mockResolvedValue(createSnapshot({}))
|
||||||
|
|
||||||
|
const response = await GET()
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(payload[0]).not.toHaveProperty("health")
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(["a malformed snapshot", "an unknown version", "a Blob 500"])(
|
||||||
|
"fails open for %s",
|
||||||
|
async (failure) => {
|
||||||
|
loadSnapshot.mockRejectedValue(new Error(failure))
|
||||||
|
|
||||||
|
const response = await GET()
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(payload[0]).not.toHaveProperty("health")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it("fails open when the snapshot request times out", async () => {
|
||||||
|
loadSnapshot.mockImplementation(
|
||||||
|
({ abortSignal }) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
abortSignal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const responsePromise = GET()
|
||||||
|
await vi.advanceTimersByTimeAsync(2500)
|
||||||
|
const response = await responsePromise
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
expect(payload[0]).not.toHaveProperty("health")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fails open when the snapshot is missing", async () => {
|
||||||
|
loadSnapshot.mockResolvedValue(null)
|
||||||
|
|
||||||
|
const response = await GET()
|
||||||
|
const payload = await response.json()
|
||||||
|
|
||||||
|
expect(payload[0]).not.toHaveProperty("health")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,16 +1,63 @@
|
|||||||
import { NextResponse } from "next/server"
|
import { NextResponse } from "next/server"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createPublicRegistryDirectory,
|
||||||
|
registryDirectorySchema,
|
||||||
|
} from "@/lib/registry-directory"
|
||||||
|
import { loadRegistryHealthSnapshot } from "@/lib/registry-health/blob"
|
||||||
import directory from "@/registry/directory.json"
|
import directory from "@/registry/directory.json"
|
||||||
|
|
||||||
export const dynamic = "force-static"
|
export const dynamic = "force-static"
|
||||||
|
export const revalidate = 300
|
||||||
|
|
||||||
|
const SNAPSHOT_STALE_AFTER_MS = 6 * 60 * 60 * 1000
|
||||||
|
const SNAPSHOT_TIMEOUT_MS = 2500
|
||||||
|
const registries = createPublicRegistryDirectory(
|
||||||
|
registryDirectorySchema.parse(directory)
|
||||||
|
)
|
||||||
|
|
||||||
|
async function getHealthSnapshot() {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), SNAPSHOT_TIMEOUT_MS)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const snapshot = await loadRegistryHealthSnapshot({
|
||||||
|
token: process.env.BLOB_READ_WRITE_TOKEN,
|
||||||
|
abortSignal: controller.signal,
|
||||||
|
})
|
||||||
|
if (!snapshot) throw new Error("missing_snapshot")
|
||||||
|
|
||||||
|
const age = Date.now() - new Date(snapshot.generatedAt).getTime()
|
||||||
|
if (age < 0 || age > SNAPSHOT_STALE_AFTER_MS) {
|
||||||
|
throw new Error("stale_snapshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"Registry health snapshot unavailable:",
|
||||||
|
error instanceof Error ? error.message : "unknown_error"
|
||||||
|
)
|
||||||
|
return null
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const registries = directory.map(({ name, homepage, url, description }) => ({
|
if (process.env.REGISTRY_HEALTH_ENABLED !== "1") {
|
||||||
name,
|
return NextResponse.json(registries)
|
||||||
homepage,
|
}
|
||||||
url,
|
|
||||||
description,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return NextResponse.json(registries)
|
const snapshot = await getHealthSnapshot()
|
||||||
|
if (!snapshot) {
|
||||||
|
return NextResponse.json(registries)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
registries.map((registry) => {
|
||||||
|
const health = snapshot.registries[registry.name]
|
||||||
|
return health ? { ...registry, health } : registry
|
||||||
|
})
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
title: Registry Health
|
||||||
|
description: How public registries are monitored and scored.
|
||||||
|
---
|
||||||
|
|
||||||
|
Registries can change or go offline after they are added to the directory.
|
||||||
|
Registry Health keeps checking them so users can see whether they are working
|
||||||
|
and maintainers can catch problems early.
|
||||||
|
|
||||||
|
We check that a registry is online, follows the registry format, and works with
|
||||||
|
the `shadcn` CLI. These checks are combined into a status and a score.
|
||||||
|
|
||||||
|
<Callout title="Registry Health is experimental and not live yet.">
|
||||||
|
Scores are collected but do not yet affect registry visibility or ordering.
|
||||||
|
The scoring model and its thresholds may change before launch.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
**Registry Health only applies to registries listed in the shadcn/ui Registry
|
||||||
|
Directory.** It does not monitor or affect private registries, registries
|
||||||
|
configured directly in your project, or GitHub registries used through
|
||||||
|
`owner/repo/item` addresses.
|
||||||
|
|
||||||
|
Monitoring starts after a registry is published. It does not decide whether a
|
||||||
|
registry can be added, and failed checks do not unpublish it. A future version
|
||||||
|
of the Registry Directory may hide an unavailable registry from normal
|
||||||
|
browsing without removing it from the API.
|
||||||
|
|
||||||
|
## What we check
|
||||||
|
|
||||||
|
| Cadence | Check | What we look for |
|
||||||
|
| ------- | -------------- | ----------------------------------------------------------- |
|
||||||
|
| Hourly | Registry index | The index is online, valid, and correctly configured. |
|
||||||
|
| Daily | Registry items | A rotating sample of items can be downloaded and validated. |
|
||||||
|
| Weekly | CLI | A rotating item works with `shadcn add --dry-run`. |
|
||||||
|
|
||||||
|
Item checks rotate through the catalog over approximately 30 days. Scheduled
|
||||||
|
runs are best-effort, so we use the time of each real observation instead of
|
||||||
|
assuming that every scheduled check ran.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
The status tells you how a registry is doing right now:
|
||||||
|
|
||||||
|
| Status | What it means |
|
||||||
|
| --------------- | ------------------------------------------------------------------------- |
|
||||||
|
| **Observing** | We are collecting the first 24 index checks over at least 24 hours. |
|
||||||
|
| **Healthy** | The initial observation period is complete and recent checks are passing. |
|
||||||
|
| **Degraded** | The registry is online, but one or more recent checks are failing. |
|
||||||
|
| **Unavailable** | The registry index has not passed a check for at least 24 hours. |
|
||||||
|
|
||||||
|
A registry can become **Unavailable** before the initial observation period is
|
||||||
|
complete. We mark a registry as **Degraded** when:
|
||||||
|
|
||||||
|
- The index fails three checks in a row.
|
||||||
|
- The latest index does not match the registry schema.
|
||||||
|
- Fewer than 90% of sampled items pass after at least 10 checks.
|
||||||
|
- The two most recent CLI checks fail.
|
||||||
|
|
||||||
|
The status can react to a recent problem before the overall score changes much.
|
||||||
|
This means a registry can have a high score and still be **Degraded**.
|
||||||
|
|
||||||
|
Each status includes a short, human-readable reason. API consumers can use the
|
||||||
|
stable `statusReason.code` or display `statusReason.message`. Raw errors from
|
||||||
|
the monitor are not published.
|
||||||
|
|
||||||
|
After an availability failure, a registry needs two successful index and schema
|
||||||
|
checks in a row to recover. Other degradations clear when their failing checks
|
||||||
|
return to healthy levels.
|
||||||
|
|
||||||
|
## Score
|
||||||
|
|
||||||
|
Every registry receives one score out of **100 points**. It is the sum of four
|
||||||
|
components. The component values are not separate scores out of 100.
|
||||||
|
|
||||||
|
| Component | Points | What it measures |
|
||||||
|
| -------------- | -----: | ----------------------------------------------------------------------- |
|
||||||
|
| Reliability | 45 | How often the registry index was available over the last 7 and 30 days. |
|
||||||
|
| Correctness | 25 | Whether the index and sampled items match the registry schema. |
|
||||||
|
| Installability | 20 | Whether sampled items work with the `shadcn` CLI. |
|
||||||
|
| Registry setup | 10 | HTTPS, JSON responses, unique item names, and a matching registry name. |
|
||||||
|
|
||||||
|
The score is about reliability and compatibility. It is not a measure of
|
||||||
|
popularity, code quality, design quality, or how many items a registry contains.
|
||||||
|
|
||||||
|
### How the points are calculated
|
||||||
|
|
||||||
|
Recent availability matters more than older availability:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Reliability = 45 * (0.65 * availability7d + 0.35 * availability30d)
|
||||||
|
```
|
||||||
|
|
||||||
|
Correctness gives up to 10 points for the index and 15 points for sampled items:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Correctness = 10 * indexSchemaPassRate30d
|
||||||
|
+ 15 * sampledItemPassRate30d
|
||||||
|
```
|
||||||
|
|
||||||
|
Installability is based on CLI checks:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Installability = 20 * dryRunPassRate30d
|
||||||
|
```
|
||||||
|
|
||||||
|
Registry setup has four checks worth 2.5 points each. If we have not observed a
|
||||||
|
setup signal yet, it receives 1.25 points until it can be checked.
|
||||||
|
|
||||||
|
Each component is rounded to three decimal places. The published score is the
|
||||||
|
sum of those rounded values.
|
||||||
|
|
||||||
|
## Scores for new registries
|
||||||
|
|
||||||
|
A new registry does not have enough history for a reliable score. We blend its
|
||||||
|
early results with the average across monitored registries. As more checks are
|
||||||
|
collected, the registry's own results have more influence.
|
||||||
|
|
||||||
|
This prevents one successful check from producing a perfect score and one
|
||||||
|
failed check from producing a zero. It also explains why an **Observing**
|
||||||
|
registry can already have a score close to the overall average.
|
||||||
|
|
||||||
|
For API consumers, the smoothing formula is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
(successes + globalMean * priorWeight) / (observations + priorWeight)
|
||||||
|
```
|
||||||
|
|
||||||
|
The prior weight follows the cadence of each check:
|
||||||
|
|
||||||
|
| Signal | Prior weight |
|
||||||
|
| --------------------- | -----------: |
|
||||||
|
| Availability | 24 |
|
||||||
|
| Index schema validity | 12 |
|
||||||
|
| Sampled item validity | 10 |
|
||||||
|
| CLI checks | 3 |
|
||||||
|
|
||||||
|
When there is not enough registry-wide data to calculate an average, we start
|
||||||
|
with an 85% availability prior and a 90% prior for the other measured rates. A
|
||||||
|
change to the formula, weights, or thresholds requires a new `scoreVersion`.
|
||||||
|
|
||||||
|
## Health data in the API
|
||||||
|
|
||||||
|
`/r/registries.json` adds an optional `health` object to each registry. It
|
||||||
|
includes:
|
||||||
|
|
||||||
|
- The current `status` and `statusReason`.
|
||||||
|
- The overall `score` and its component `breakdown`.
|
||||||
|
- Smoothed `availability7d` and `availability30d` rates from 0 to 1.
|
||||||
|
- `firstObservedAt`, `checkedAt`, and `lastSuccessfulCheck` timestamps.
|
||||||
|
- `schemaVersion` and `scoreVersion` for integrations.
|
||||||
|
|
||||||
|
The object also includes two flags:
|
||||||
|
|
||||||
|
- `monitoringLimited` means the latest registry index request was blocked by a
|
||||||
|
CDN or WAF challenge. Challenge responses do not count as availability or
|
||||||
|
sampled item validation failures.
|
||||||
|
- `hidden` becomes `true` after seven continuous days of unavailability.
|
||||||
|
|
||||||
|
The Registry Directory does not currently use the `hidden` flag.
|
||||||
|
|
||||||
|
## Monitoring limitations
|
||||||
|
|
||||||
|
All checks come from one hosted runner. Latency is therefore kept as a private
|
||||||
|
diagnostic and does not affect the score because some regions would have an
|
||||||
|
unfair advantage.
|
||||||
|
|
||||||
|
CDN and WAF challenges are also handled separately. A challenge tells us that
|
||||||
|
our runner could not complete the check, not that the registry is unavailable
|
||||||
|
to everyone.
|
||||||
|
|
||||||
|
## What happens next
|
||||||
|
|
||||||
|
We will collect and review baseline data before using Registry Health in the
|
||||||
|
Directory. Ranking, filtering, and health UI will ship separately once the
|
||||||
|
monitoring data is reliable.
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"getting-started",
|
"getting-started",
|
||||||
"github",
|
"github",
|
||||||
"registry-index",
|
"registry-index",
|
||||||
|
"health",
|
||||||
"examples",
|
"examples",
|
||||||
"namespace",
|
"namespace",
|
||||||
"authentication",
|
"authentication",
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ namespaces such as `@acme`.
|
|||||||
|
|
||||||
Once you have submitted your request, it will be validated and reviewed by the team.
|
Once you have submitted your request, it will be validated and reviewed by the team.
|
||||||
|
|
||||||
|
Once the pull request is merged, your registry is published immediately.
|
||||||
|
[Registry Health](/docs/registry/health) starts monitoring it after publication
|
||||||
|
and does not delay or gate publication while it collects baseline data.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
1. The registry must be open source and publicly accessible.
|
1. The registry must be open source and publicly accessible.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const PAGES_NEW = [
|
|||||||
"/docs/components/base/questionnaire",
|
"/docs/components/base/questionnaire",
|
||||||
"/docs/components/aria/questionnaire",
|
"/docs/components/aria/questionnaire",
|
||||||
"/docs/react/questionnaire",
|
"/docs/react/questionnaire",
|
||||||
|
"/docs/registry/health",
|
||||||
]
|
]
|
||||||
|
|
||||||
export const PAGES_UPDATED = []
|
export const PAGES_UPDATED = []
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
const registryNamespaceSchema = z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9_-]*$/)
|
||||||
|
|
||||||
|
const registryDirectoryEntrySchema = z
|
||||||
|
.object({
|
||||||
|
name: registryNamespaceSchema,
|
||||||
|
homepage: z.string().url(),
|
||||||
|
url: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.refine((url) => url.includes("{name}"), {
|
||||||
|
message: "URL must include {name} placeholder",
|
||||||
|
}),
|
||||||
|
description: z.string(),
|
||||||
|
author: z.string().optional(),
|
||||||
|
logo: z.string(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
const registryDirectorySchema = z
|
||||||
|
.array(registryDirectoryEntrySchema)
|
||||||
|
.superRefine((entries, context) => {
|
||||||
|
const names = new Set<string>()
|
||||||
|
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const name = entry.name.toLowerCase()
|
||||||
|
if (names.has(name)) {
|
||||||
|
context.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
message: `Duplicate registry namespace: ${entry.name}`,
|
||||||
|
path: [index, "name"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
names.add(name)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const publicRegistryDirectoryEntrySchema = registryDirectoryEntrySchema.pick({
|
||||||
|
name: true,
|
||||||
|
homepage: true,
|
||||||
|
url: true,
|
||||||
|
description: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const publicRegistryDirectorySchema = z.array(
|
||||||
|
publicRegistryDirectoryEntrySchema
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegistryDirectoryEntry = z.infer<typeof registryDirectoryEntrySchema>
|
||||||
|
|
||||||
|
function createPublicRegistryDirectory(
|
||||||
|
entries: readonly RegistryDirectoryEntry[]
|
||||||
|
) {
|
||||||
|
return entries.map(({ name, homepage, url, description }) => ({
|
||||||
|
name,
|
||||||
|
homepage,
|
||||||
|
url,
|
||||||
|
description,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRegistryName(value: string) {
|
||||||
|
return value.toLowerCase().replaceAll(" ", "").replace(/^@/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
createPublicRegistryDirectory,
|
||||||
|
normalizeRegistryName,
|
||||||
|
publicRegistryDirectoryEntrySchema,
|
||||||
|
publicRegistryDirectorySchema,
|
||||||
|
registryDirectoryEntrySchema,
|
||||||
|
registryDirectorySchema,
|
||||||
|
registryNamespaceSchema,
|
||||||
|
}
|
||||||
|
export type { RegistryDirectoryEntry }
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
cleanRegistryHealthHistory,
|
||||||
|
loadRegistryHealthSnapshot,
|
||||||
|
loadRegistryMonitorState,
|
||||||
|
publishRegistryHealth,
|
||||||
|
type BlobOperations,
|
||||||
|
} from "./blob"
|
||||||
|
import type {
|
||||||
|
RegistryHealthSnapshot,
|
||||||
|
RegistryMonitorRun,
|
||||||
|
RegistryMonitorState,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
function streamJson(value: unknown) {
|
||||||
|
const bytes = new TextEncoder().encode(JSON.stringify(value))
|
||||||
|
return new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(bytes)
|
||||||
|
controller.close()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOperations(overrides: Partial<BlobOperations> = {}) {
|
||||||
|
return {
|
||||||
|
get: vi.fn(async () => null),
|
||||||
|
put: vi.fn(async (pathname) => ({
|
||||||
|
url: `https://blob.example.com/${pathname}`,
|
||||||
|
})),
|
||||||
|
list: vi.fn(async () => ({ blobs: [], hasMore: false })),
|
||||||
|
del: vi.fn(async () => {}),
|
||||||
|
...overrides,
|
||||||
|
} satisfies BlobOperations
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATE: RegistryMonitorState = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
scoreVersion: 1,
|
||||||
|
updatedAt: "2026-08-24T12:00:00.000Z",
|
||||||
|
registries: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const SNAPSHOT: RegistryHealthSnapshot = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
scoreVersion: 1,
|
||||||
|
generatedAt: "2026-08-24T12:00:00.000Z",
|
||||||
|
globalMeans: {
|
||||||
|
availability7d: 0.85,
|
||||||
|
availability30d: 0.85,
|
||||||
|
indexSchema: 0.9,
|
||||||
|
itemValidity: 0.9,
|
||||||
|
dryRun: 0.9,
|
||||||
|
},
|
||||||
|
registries: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const RUN: RegistryMonitorRun = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
startedAt: "2026-08-24T12:00:00.000Z",
|
||||||
|
completedAt: "2026-08-24T12:01:00.000Z",
|
||||||
|
mode: "hourly",
|
||||||
|
totals: {
|
||||||
|
registries: 0,
|
||||||
|
reachable: 0,
|
||||||
|
unavailable: 0,
|
||||||
|
challenges: 0,
|
||||||
|
itemChecks: 0,
|
||||||
|
dryRuns: 0,
|
||||||
|
},
|
||||||
|
results: {},
|
||||||
|
diagnostics: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("loadRegistryMonitorState", () => {
|
||||||
|
it("returns null on the first run", async () => {
|
||||||
|
const state = await loadRegistryMonitorState({
|
||||||
|
token: "test-token",
|
||||||
|
operations: createOperations(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(state).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("validates stored state before using it", async () => {
|
||||||
|
const operations = createOperations({
|
||||||
|
get: vi.fn(async () => ({ stream: streamJson(STATE) })),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadRegistryMonitorState({ token: "test-token", operations })
|
||||||
|
).resolves.toEqual(STATE)
|
||||||
|
expect(operations.get).toHaveBeenCalledWith(
|
||||||
|
"registry-health/v1/state.json",
|
||||||
|
{
|
||||||
|
access: "private",
|
||||||
|
token: "test-token",
|
||||||
|
useCache: false,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("loadRegistryHealthSnapshot", () => {
|
||||||
|
it("reads and validates the private latest snapshot", async () => {
|
||||||
|
const abortController = new AbortController()
|
||||||
|
const operations = createOperations({
|
||||||
|
get: vi.fn(async () => ({ stream: streamJson(SNAPSHOT) })),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadRegistryHealthSnapshot({
|
||||||
|
token: "test-token",
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
operations,
|
||||||
|
})
|
||||||
|
).resolves.toEqual(SNAPSHOT)
|
||||||
|
expect(operations.get).toHaveBeenCalledWith(
|
||||||
|
"registry-health/v1/latest.json",
|
||||||
|
{
|
||||||
|
access: "private",
|
||||||
|
token: "test-token",
|
||||||
|
useCache: false,
|
||||||
|
abortSignal: abortController.signal,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects an invalid latest snapshot", async () => {
|
||||||
|
const operations = createOperations({
|
||||||
|
get: vi.fn(async () => ({
|
||||||
|
stream: streamJson({ ...SNAPSHOT, schemaVersion: 2 }),
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loadRegistryHealthSnapshot({ token: "test-token", operations })
|
||||||
|
).rejects.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("publishRegistryHealth", () => {
|
||||||
|
it("publishes immutable history before overwriting state and latest", async () => {
|
||||||
|
const operations = createOperations()
|
||||||
|
const result = await publishRegistryHealth({
|
||||||
|
state: STATE,
|
||||||
|
snapshot: SNAPSHOT,
|
||||||
|
run: RUN,
|
||||||
|
token: "test-token",
|
||||||
|
operations,
|
||||||
|
})
|
||||||
|
const calls = vi.mocked(operations.put).mock.calls
|
||||||
|
|
||||||
|
expect(calls.map(([pathname]) => pathname)).toEqual([
|
||||||
|
"registry-health/v1/runs/2026-08-24T12-00-00-000Z.json",
|
||||||
|
"registry-health/v1/daily/2026-08-24.json",
|
||||||
|
"registry-health/v1/state.json",
|
||||||
|
"registry-health/v1/latest.json",
|
||||||
|
])
|
||||||
|
expect(calls[0][2].allowOverwrite).toBe(false)
|
||||||
|
expect(calls[2][2].allowOverwrite).toBe(true)
|
||||||
|
expect(calls[3][2].allowOverwrite).toBe(true)
|
||||||
|
expect(calls.every(([, , options]) => options.access === "private")).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(result.latestPath).toBe("registry-health/v1/latest.json")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("cleanRegistryHealthHistory", () => {
|
||||||
|
it("paginates listings and deletes expired history", async () => {
|
||||||
|
const list = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
blobs: [
|
||||||
|
{
|
||||||
|
pathname: "registry-health/v1/runs/old.json",
|
||||||
|
uploadedAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cursor: "next",
|
||||||
|
hasMore: true,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ blobs: [], hasMore: false })
|
||||||
|
.mockResolvedValueOnce({ blobs: [], hasMore: false })
|
||||||
|
const operations = createOperations({ list })
|
||||||
|
|
||||||
|
const result = await cleanRegistryHealthHistory({
|
||||||
|
token: "test-token",
|
||||||
|
now: new Date("2026-08-24T12:00:00.000Z"),
|
||||||
|
operations,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(list).toHaveBeenCalledTimes(3)
|
||||||
|
expect(operations.del).toHaveBeenCalledWith(
|
||||||
|
["registry-health/v1/runs/old.json"],
|
||||||
|
{ token: "test-token" }
|
||||||
|
)
|
||||||
|
expect(result.deleted).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import {
|
||||||
|
del as deleteBlob,
|
||||||
|
get as getBlob,
|
||||||
|
list as listBlobs,
|
||||||
|
put as putBlob,
|
||||||
|
} from "@vercel/blob"
|
||||||
|
|
||||||
|
import {
|
||||||
|
registryHealthSnapshotSchema,
|
||||||
|
registryMonitorStateSchema,
|
||||||
|
type RegistryHealthSnapshot,
|
||||||
|
type RegistryMonitorRun,
|
||||||
|
type RegistryMonitorState,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
const BLOB_PREFIX = "registry-health/v1"
|
||||||
|
const STATE_PATH = `${BLOB_PREFIX}/state.json`
|
||||||
|
const LATEST_PATH = `${BLOB_PREFIX}/latest.json`
|
||||||
|
|
||||||
|
type BlobListItem = {
|
||||||
|
pathname: string
|
||||||
|
uploadedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type BlobOperations = {
|
||||||
|
get: (
|
||||||
|
pathname: string,
|
||||||
|
options: {
|
||||||
|
access: "private"
|
||||||
|
token?: string
|
||||||
|
useCache: false
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
}
|
||||||
|
) => Promise<{ stream: ReadableStream<Uint8Array> | null } | null>
|
||||||
|
put: (
|
||||||
|
pathname: string,
|
||||||
|
body: string,
|
||||||
|
options: {
|
||||||
|
access: "private"
|
||||||
|
token?: string
|
||||||
|
addRandomSuffix: false
|
||||||
|
allowOverwrite?: boolean
|
||||||
|
cacheControlMaxAge: number
|
||||||
|
contentType: "application/json"
|
||||||
|
}
|
||||||
|
) => Promise<{ url: string }>
|
||||||
|
list: (options: {
|
||||||
|
prefix: string
|
||||||
|
cursor?: string
|
||||||
|
limit: number
|
||||||
|
token?: string
|
||||||
|
}) => Promise<{
|
||||||
|
blobs: BlobListItem[]
|
||||||
|
cursor?: string
|
||||||
|
hasMore: boolean
|
||||||
|
}>
|
||||||
|
del: (pathnames: string[], options: { token?: string }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultOperations: BlobOperations = {
|
||||||
|
get: getBlob,
|
||||||
|
put: putBlob,
|
||||||
|
list: listBlobs,
|
||||||
|
del: deleteBlob,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJsonStream(stream: ReadableStream<Uint8Array>) {
|
||||||
|
const reader = stream.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let size = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
chunks.push(value)
|
||||||
|
size += value.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new Uint8Array(size)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
body.set(chunk, offset)
|
||||||
|
offset += chunk.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parse(new TextDecoder().decode(body)) as unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRegistryMonitorState({
|
||||||
|
token,
|
||||||
|
operations = defaultOperations,
|
||||||
|
}: {
|
||||||
|
token?: string
|
||||||
|
operations?: BlobOperations
|
||||||
|
}) {
|
||||||
|
const result = await operations.get(STATE_PATH, {
|
||||||
|
access: "private",
|
||||||
|
token,
|
||||||
|
useCache: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result?.stream) return null
|
||||||
|
|
||||||
|
return registryMonitorStateSchema.parse(await readJsonStream(result.stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadRegistryHealthSnapshot({
|
||||||
|
token,
|
||||||
|
abortSignal,
|
||||||
|
operations = defaultOperations,
|
||||||
|
}: {
|
||||||
|
token?: string
|
||||||
|
abortSignal?: AbortSignal
|
||||||
|
operations?: BlobOperations
|
||||||
|
}) {
|
||||||
|
const result = await operations.get(LATEST_PATH, {
|
||||||
|
access: "private",
|
||||||
|
token,
|
||||||
|
useCache: false,
|
||||||
|
abortSignal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result?.stream) return null
|
||||||
|
|
||||||
|
return registryHealthSnapshotSchema.parse(await readJsonStream(result.stream))
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRunPath(run: RegistryMonitorRun) {
|
||||||
|
const timestamp = run.startedAt.replaceAll(":", "-").replaceAll(".", "-")
|
||||||
|
return `${BLOB_PREFIX}/runs/${timestamp}.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDailyPath(snapshot: RegistryHealthSnapshot) {
|
||||||
|
return `${BLOB_PREFIX}/daily/${snapshot.generatedAt.slice(0, 10)}.json`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDailyDocument(
|
||||||
|
state: RegistryMonitorState,
|
||||||
|
snapshot: RegistryHealthSnapshot
|
||||||
|
) {
|
||||||
|
const date = snapshot.generatedAt.slice(0, 10)
|
||||||
|
return {
|
||||||
|
schemaVersion: snapshot.schemaVersion,
|
||||||
|
scoreVersion: snapshot.scoreVersion,
|
||||||
|
date,
|
||||||
|
generatedAt: snapshot.generatedAt,
|
||||||
|
globalMeans: snapshot.globalMeans,
|
||||||
|
registries: Object.fromEntries(
|
||||||
|
Object.entries(state.registries).map(([name, entry]) => [
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
aggregate: entry.daily.find((bucket) => bucket.date === date) ?? null,
|
||||||
|
health: snapshot.registries[name],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function putJson({
|
||||||
|
pathname,
|
||||||
|
value,
|
||||||
|
token,
|
||||||
|
allowOverwrite = false,
|
||||||
|
operations,
|
||||||
|
}: {
|
||||||
|
pathname: string
|
||||||
|
value: unknown
|
||||||
|
token?: string
|
||||||
|
allowOverwrite?: boolean
|
||||||
|
operations: BlobOperations
|
||||||
|
}) {
|
||||||
|
return operations.put(pathname, JSON.stringify(value), {
|
||||||
|
access: "private",
|
||||||
|
token,
|
||||||
|
addRandomSuffix: false,
|
||||||
|
allowOverwrite,
|
||||||
|
cacheControlMaxAge: 60,
|
||||||
|
contentType: "application/json",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishRegistryHealth({
|
||||||
|
state,
|
||||||
|
snapshot,
|
||||||
|
run,
|
||||||
|
token,
|
||||||
|
operations = defaultOperations,
|
||||||
|
}: {
|
||||||
|
state: RegistryMonitorState
|
||||||
|
snapshot: RegistryHealthSnapshot
|
||||||
|
run: RegistryMonitorRun
|
||||||
|
token?: string
|
||||||
|
operations?: BlobOperations
|
||||||
|
}) {
|
||||||
|
await putJson({
|
||||||
|
pathname: getRunPath(run),
|
||||||
|
value: run,
|
||||||
|
token,
|
||||||
|
operations,
|
||||||
|
})
|
||||||
|
await putJson({
|
||||||
|
pathname: getDailyPath(snapshot),
|
||||||
|
value: getDailyDocument(state, snapshot),
|
||||||
|
token,
|
||||||
|
allowOverwrite: true,
|
||||||
|
operations,
|
||||||
|
})
|
||||||
|
await putJson({
|
||||||
|
pathname: STATE_PATH,
|
||||||
|
value: state,
|
||||||
|
token,
|
||||||
|
allowOverwrite: true,
|
||||||
|
operations,
|
||||||
|
})
|
||||||
|
await putJson({
|
||||||
|
pathname: LATEST_PATH,
|
||||||
|
value: snapshot,
|
||||||
|
token,
|
||||||
|
allowOverwrite: true,
|
||||||
|
operations,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { latestPath: LATEST_PATH }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAll({
|
||||||
|
prefix,
|
||||||
|
token,
|
||||||
|
operations,
|
||||||
|
}: {
|
||||||
|
prefix: string
|
||||||
|
token?: string
|
||||||
|
operations: BlobOperations
|
||||||
|
}) {
|
||||||
|
const blobs: BlobListItem[] = []
|
||||||
|
let cursor: string | undefined
|
||||||
|
|
||||||
|
do {
|
||||||
|
const page = await operations.list({
|
||||||
|
prefix,
|
||||||
|
cursor,
|
||||||
|
limit: 1000,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
blobs.push(...page.blobs)
|
||||||
|
cursor = page.hasMore ? page.cursor : undefined
|
||||||
|
} while (cursor)
|
||||||
|
|
||||||
|
return blobs
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanRegistryHealthHistory({
|
||||||
|
token,
|
||||||
|
now = new Date(),
|
||||||
|
operations = defaultOperations,
|
||||||
|
}: {
|
||||||
|
token?: string
|
||||||
|
now?: Date
|
||||||
|
operations?: BlobOperations
|
||||||
|
}) {
|
||||||
|
const policies = [
|
||||||
|
{ prefix: `${BLOB_PREFIX}/runs/`, retentionDays: 14 },
|
||||||
|
{ prefix: `${BLOB_PREFIX}/daily/`, retentionDays: 90 },
|
||||||
|
]
|
||||||
|
const stale: string[] = []
|
||||||
|
|
||||||
|
for (const policy of policies) {
|
||||||
|
const blobs = await listAll({
|
||||||
|
prefix: policy.prefix,
|
||||||
|
token,
|
||||||
|
operations,
|
||||||
|
})
|
||||||
|
const cutoff = now.getTime() - policy.retentionDays * 24 * 60 * 60 * 1000
|
||||||
|
stale.push(
|
||||||
|
...blobs
|
||||||
|
.filter((blob) => blob.uploadedAt.getTime() < cutoff)
|
||||||
|
.map((blob) => blob.pathname)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = 0; index < stale.length; index += 100) {
|
||||||
|
await operations.del(stale.slice(index, index + 100), { token })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted: stale.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BLOB_PREFIX, LATEST_PATH, STATE_PATH }
|
||||||
|
export type { BlobOperations }
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { getDryRunEnvironment, runLocalCliDryRun } from "./dry-run"
|
||||||
|
|
||||||
|
describe("getDryRunEnvironment", () => {
|
||||||
|
it("allowlists process variables and excludes credentials", () => {
|
||||||
|
const environment = getDryRunEnvironment("/tmp/registry-health-test")
|
||||||
|
|
||||||
|
expect(environment).toMatchObject({
|
||||||
|
CI: "1",
|
||||||
|
NO_COLOR: "1",
|
||||||
|
XDG_CACHE_HOME: "/tmp/registry-health-test",
|
||||||
|
XDG_CONFIG_HOME: "/tmp/registry-health-test",
|
||||||
|
})
|
||||||
|
expect(environment).not.toHaveProperty("BLOB_READ_WRITE_TOKEN")
|
||||||
|
expect(environment).not.toHaveProperty("GITHUB_TOKEN")
|
||||||
|
expect(environment).not.toHaveProperty("GH_TOKEN")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("runLocalCliDryRun", () => {
|
||||||
|
it("force kills a CLI process that ignores SIGTERM", async () => {
|
||||||
|
const directory = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "registry-health-cli-test-")
|
||||||
|
)
|
||||||
|
const cliPath = path.join(directory, "cli.mjs")
|
||||||
|
await fs.writeFile(
|
||||||
|
cliPath,
|
||||||
|
'process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runLocalCliDryRun({
|
||||||
|
namespace: "@acme",
|
||||||
|
item: "button",
|
||||||
|
registryUrl: "https://acme.example.com/r/{name}.json",
|
||||||
|
cliPath,
|
||||||
|
timeoutMs: 25,
|
||||||
|
killGraceMs: 25,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: false,
|
||||||
|
failureCode: "timeout",
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await fs.rm(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { spawn, type ChildProcess } from "node:child_process"
|
||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
import type { RegistryDryRunResult } from "./monitor"
|
||||||
|
|
||||||
|
async function runLocalCliDryRun({
|
||||||
|
namespace,
|
||||||
|
item,
|
||||||
|
registryUrl,
|
||||||
|
cliPath,
|
||||||
|
timeoutMs = 60_000,
|
||||||
|
killGraceMs = 5_000,
|
||||||
|
}: {
|
||||||
|
namespace: string
|
||||||
|
item: string
|
||||||
|
registryUrl: string
|
||||||
|
cliPath: string
|
||||||
|
timeoutMs?: number
|
||||||
|
killGraceMs?: number
|
||||||
|
}): Promise<RegistryDryRunResult> {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
const temporaryDirectory = await fs.mkdtemp(
|
||||||
|
path.join(os.tmpdir(), "shadcn-registry-health-")
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.join(temporaryDirectory, "app"), { recursive: true })
|
||||||
|
await Promise.all([
|
||||||
|
fs.writeFile(
|
||||||
|
path.join(temporaryDirectory, "components.json"),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
$schema: "https://ui.shadcn.com/schema.json",
|
||||||
|
style: "new-york",
|
||||||
|
rsc: true,
|
||||||
|
tsx: true,
|
||||||
|
tailwind: {
|
||||||
|
config: "",
|
||||||
|
css: "app/globals.css",
|
||||||
|
baseColor: "neutral",
|
||||||
|
cssVariables: true,
|
||||||
|
prefix: "",
|
||||||
|
},
|
||||||
|
iconLibrary: "lucide",
|
||||||
|
aliases: {
|
||||||
|
components: "@/components",
|
||||||
|
utils: "@/lib/utils",
|
||||||
|
ui: "@/components/ui",
|
||||||
|
lib: "@/lib",
|
||||||
|
hooks: "@/hooks",
|
||||||
|
},
|
||||||
|
registries: {
|
||||||
|
[namespace]: registryUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
),
|
||||||
|
fs.writeFile(path.join(temporaryDirectory, "app/globals.css"), ""),
|
||||||
|
fs.writeFile(
|
||||||
|
path.join(temporaryDirectory, "package.json"),
|
||||||
|
JSON.stringify({ private: true })
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
return await new Promise<RegistryDryRunResult>((resolve) => {
|
||||||
|
const child: ChildProcess = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
cliPath,
|
||||||
|
"add",
|
||||||
|
`${namespace}/${item}`,
|
||||||
|
"--dry-run",
|
||||||
|
"--yes",
|
||||||
|
"--cwd",
|
||||||
|
temporaryDirectory,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: temporaryDirectory,
|
||||||
|
env: getDryRunEnvironment(temporaryDirectory),
|
||||||
|
stdio: "ignore",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let completed = false
|
||||||
|
let timedOut = false
|
||||||
|
let forceKillTimeout: NodeJS.Timeout | undefined
|
||||||
|
const finish = (result: RegistryDryRunResult) => {
|
||||||
|
if (completed) return
|
||||||
|
completed = true
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (forceKillTimeout) clearTimeout(forceKillTimeout)
|
||||||
|
resolve(result)
|
||||||
|
}
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
timedOut = true
|
||||||
|
child.kill("SIGTERM")
|
||||||
|
forceKillTimeout = setTimeout(() => {
|
||||||
|
if (!completed) child.kill("SIGKILL")
|
||||||
|
}, killGraceMs)
|
||||||
|
}, timeoutMs)
|
||||||
|
|
||||||
|
child.on("error", () =>
|
||||||
|
finish({
|
||||||
|
success: false,
|
||||||
|
failureCode: timedOut ? "timeout" : "spawn_error",
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
const failureCode = timedOut
|
||||||
|
? "timeout"
|
||||||
|
: code === 0
|
||||||
|
? undefined
|
||||||
|
: signal
|
||||||
|
? "terminated"
|
||||||
|
: `exit_${code}`
|
||||||
|
|
||||||
|
finish({
|
||||||
|
success: !timedOut && code === 0,
|
||||||
|
failureCode,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await fs.rm(temporaryDirectory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDryRunEnvironment(temporaryDirectory: string) {
|
||||||
|
return {
|
||||||
|
PATH: process.env.PATH,
|
||||||
|
TMPDIR: os.tmpdir(),
|
||||||
|
XDG_CACHE_HOME: temporaryDirectory,
|
||||||
|
XDG_CONFIG_HOME: temporaryDirectory,
|
||||||
|
npm_config_userconfig: path.join(temporaryDirectory, ".npmrc"),
|
||||||
|
NODE_ENV: "production",
|
||||||
|
CI: "1",
|
||||||
|
NO_COLOR: "1",
|
||||||
|
} satisfies NodeJS.ProcessEnv
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getDryRunEnvironment, runLocalCliDryRun }
|
||||||
@@ -0,0 +1,613 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import type { RegistryDirectoryEntry } from "../registry-directory"
|
||||||
|
import { runRegistryMonitor } from "./monitor"
|
||||||
|
import type { RegistryMonitorEntryState, RegistryMonitorState } from "./schema"
|
||||||
|
|
||||||
|
const NOW = new Date("2026-08-24T12:00:00.000Z")
|
||||||
|
const DIRECTORY: RegistryDirectoryEntry[] = [
|
||||||
|
{
|
||||||
|
name: "@acme",
|
||||||
|
homepage: "https://acme.example.com",
|
||||||
|
url: "https://acme.example.com/r/{name}.json",
|
||||||
|
description: "Acme components",
|
||||||
|
logo: "https://acme.example.com/logo.svg",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
function createEntryState(overrides: Partial<RegistryMonitorEntryState> = {}) {
|
||||||
|
return {
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck: "2026-08-24T10:00:00.000Z",
|
||||||
|
status: "healthy",
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityRecoveryRequired: false,
|
||||||
|
itemCursor: 0,
|
||||||
|
itemNames: [],
|
||||||
|
recentIndex: [],
|
||||||
|
recentDryRuns: [],
|
||||||
|
daily: [],
|
||||||
|
latestHygiene: {
|
||||||
|
contentTypeJson: null,
|
||||||
|
noDuplicateNames: null,
|
||||||
|
nameMatches: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} satisfies RegistryMonitorEntryState
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(entry = createEntryState()) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
scoreVersion: 1,
|
||||||
|
updatedAt: "2026-08-24T10:00:00.000Z",
|
||||||
|
registries: { "@acme": entry },
|
||||||
|
} satisfies RegistryMonitorState
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSuccessfulFetch(itemCount = 1) {
|
||||||
|
return {
|
||||||
|
ok: true as const,
|
||||||
|
json: {
|
||||||
|
name: "acme",
|
||||||
|
homepage: "https://acme.example.com",
|
||||||
|
items: Array.from({ length: itemCount }, (_, index) => ({
|
||||||
|
name: `item-${index}`,
|
||||||
|
type: "registry:ui",
|
||||||
|
files: [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
durationMs: 120,
|
||||||
|
responseSize: 500,
|
||||||
|
contentType: "application/json; charset=utf-8",
|
||||||
|
redirectCount: 0,
|
||||||
|
finalUrl: "https://acme.example.com/r/registry.json",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("runRegistryMonitor", () => {
|
||||||
|
it("records completion separately from the observation time", async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2026-08-24T12:05:00.000Z"))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
mode: "hourly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.run.startedAt).toBe(NOW.toISOString())
|
||||||
|
expect(result.run.completedAt).toBe("2026-08-24T12:05:00.000Z")
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("records a successful hourly index observation", async () => {
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
mode: "hourly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => createSuccessfulFetch(2)),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.run.totals).toMatchObject({
|
||||||
|
registries: 1,
|
||||||
|
reachable: 1,
|
||||||
|
unavailable: 0,
|
||||||
|
challenges: 0,
|
||||||
|
})
|
||||||
|
expect(result.state.registries["@acme"].itemNames).toEqual([
|
||||||
|
"item-0",
|
||||||
|
"item-1",
|
||||||
|
])
|
||||||
|
expect(result.snapshot.registries["@acme"]).toMatchObject({
|
||||||
|
status: "observing",
|
||||||
|
monitoringLimited: false,
|
||||||
|
checkedAt: NOW.toISOString(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("starts an outage from the last successful index check", async () => {
|
||||||
|
const lastSuccessfulCheck = "2026-08-23T11:00:00.000Z"
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState: createState(
|
||||||
|
createEntryState({
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck,
|
||||||
|
consecutiveSuccessfulIndexes: 24,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
mode: "hourly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
failureCode: "timeout",
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: false,
|
||||||
|
durationMs: 100,
|
||||||
|
redirectCount: 0,
|
||||||
|
finalUrl: "https://acme.example.com/r/registry.json",
|
||||||
|
})),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.state.registries["@acme"]).toMatchObject({
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 1,
|
||||||
|
availabilityOutageSince: lastSuccessfulCheck,
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
})
|
||||||
|
expect(result.snapshot.registries["@acme"].status).toBe("unavailable")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("clears availability recovery after two valid index checks", async () => {
|
||||||
|
const outageSince = "2026-08-16T00:00:00.000Z"
|
||||||
|
const recentIndex = Array.from({ length: 24 }, (_, index) => ({
|
||||||
|
checkedAt: new Date(
|
||||||
|
NOW.getTime() - (24 - index) * 60 * 60 * 1000
|
||||||
|
).toISOString(),
|
||||||
|
outcome: "unreachable" as const,
|
||||||
|
durationMs: 100,
|
||||||
|
redirectCount: 0,
|
||||||
|
}))
|
||||||
|
const previousState = createState(
|
||||||
|
createEntryState({
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck: "2026-08-15T23:00:00.000Z",
|
||||||
|
status: "unavailable",
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 24,
|
||||||
|
availabilityOutageSince: outageSince,
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
recentIndex,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const first = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState,
|
||||||
|
mode: "hourly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
const recovering = first.state.registries["@acme"]
|
||||||
|
|
||||||
|
expect(recovering).toMatchObject({
|
||||||
|
consecutiveSuccessfulIndexes: 1,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityOutageSince: outageSince,
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
})
|
||||||
|
expect(first.snapshot.registries["@acme"]).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
hidden: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const second = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState: first.state,
|
||||||
|
mode: "hourly",
|
||||||
|
now: new Date("2026-08-24T13:00:00.000Z"),
|
||||||
|
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
const recovered = second.state.registries["@acme"]
|
||||||
|
|
||||||
|
expect(recovered).toMatchObject({
|
||||||
|
consecutiveSuccessfulIndexes: 2,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityRecoveryRequired: false,
|
||||||
|
})
|
||||||
|
expect(recovered).not.toHaveProperty("availabilityOutageSince")
|
||||||
|
expect(second.snapshot.registries["@acme"].status).toBe("healthy")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("records a challenge without lowering availability", async () => {
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState: createState(),
|
||||||
|
mode: "hourly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
failureCode: "bot_challenge",
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: true,
|
||||||
|
status: 403,
|
||||||
|
durationMs: 80,
|
||||||
|
responseSize: 200,
|
||||||
|
contentType: "text/html",
|
||||||
|
redirectCount: 0,
|
||||||
|
finalUrl: "https://acme.example.com/r/registry.json",
|
||||||
|
})),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bucket = result.state.registries["@acme"].daily[0]
|
||||||
|
const state = result.state.registries["@acme"]
|
||||||
|
expect(bucket.challengeObservations).toBe(1)
|
||||||
|
expect(bucket.availabilityObservations).toBe(0)
|
||||||
|
expect(state).toMatchObject({
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityRecoveryRequired: false,
|
||||||
|
})
|
||||||
|
expect(state).not.toHaveProperty("availabilityOutageSince")
|
||||||
|
expect(result.snapshot.registries["@acme"].monitoringLimited).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("allows an existing outage to become unavailable during a challenge", async () => {
|
||||||
|
const outageSince = "2026-08-23T11:00:00.000Z"
|
||||||
|
const previousState = createState(
|
||||||
|
createEntryState({
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck: outageSince,
|
||||||
|
consecutiveIndexFailures: 1,
|
||||||
|
availabilityOutageSince: outageSince,
|
||||||
|
recentIndex: [
|
||||||
|
{
|
||||||
|
checkedAt: outageSince,
|
||||||
|
outcome: "unreachable",
|
||||||
|
durationMs: 100,
|
||||||
|
redirectCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState,
|
||||||
|
mode: "hourly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
failureCode: "bot_challenge",
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: true,
|
||||||
|
status: 403,
|
||||||
|
durationMs: 80,
|
||||||
|
responseSize: 200,
|
||||||
|
contentType: "text/html",
|
||||||
|
redirectCount: 0,
|
||||||
|
finalUrl: "https://acme.example.com/r/registry.json",
|
||||||
|
})),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.state.registries["@acme"]).toMatchObject({
|
||||||
|
consecutiveIndexFailures: 1,
|
||||||
|
availabilityOutageSince: outageSince,
|
||||||
|
availabilityRecoveryRequired: false,
|
||||||
|
})
|
||||||
|
expect(result.snapshot.registries["@acme"]).toMatchObject({
|
||||||
|
status: "unavailable",
|
||||||
|
monitoringLimited: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refreshes hygiene after a reachable invalid index", async () => {
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState: createState(
|
||||||
|
createEntryState({
|
||||||
|
latestHygiene: {
|
||||||
|
contentTypeJson: true,
|
||||||
|
noDuplicateNames: true,
|
||||||
|
nameMatches: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
mode: "hourly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => ({
|
||||||
|
...createSuccessfulFetch(),
|
||||||
|
json: { invalid: true },
|
||||||
|
contentType: "text/plain",
|
||||||
|
})),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.state.registries["@acme"].latestHygiene).toEqual({
|
||||||
|
contentTypeJson: false,
|
||||||
|
noDuplicateNames: null,
|
||||||
|
nameMatches: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rotates enough daily items to cover a catalog in thirty days", async () => {
|
||||||
|
const itemNames = Array.from({ length: 61 }, (_, index) => `item-${index}`)
|
||||||
|
const fetchJson = vi.fn(async (url: string) => {
|
||||||
|
const item = url.match(/item-\d+/)?.[0] ?? "item-0"
|
||||||
|
return {
|
||||||
|
...createSuccessfulFetch(),
|
||||||
|
json: { name: item, type: "registry:ui", files: [] },
|
||||||
|
finalUrl: url,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState: createState(createEntryState({ itemNames })),
|
||||||
|
mode: "daily",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson,
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(fetchJson).toHaveBeenCalledTimes(3)
|
||||||
|
expect(result.state.registries["@acme"].itemCursor).toBe(3)
|
||||||
|
expect(result.run.totals.itemChecks).toBe(3)
|
||||||
|
expect(result.run.results["@acme"].items).toHaveLength(3)
|
||||||
|
expect(result.state.lastDailyRunAt).toBe(NOW.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("excludes item challenges from validation rates", async () => {
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState: createState(
|
||||||
|
createEntryState({
|
||||||
|
itemNames: ["button"],
|
||||||
|
daily: [
|
||||||
|
{
|
||||||
|
date: "2026-08-24",
|
||||||
|
availabilitySuccesses: 0,
|
||||||
|
availabilityObservations: 0,
|
||||||
|
challengeObservations: 0,
|
||||||
|
schemaSuccesses: 0,
|
||||||
|
schemaObservations: 0,
|
||||||
|
itemSuccesses: 8,
|
||||||
|
itemObservations: 9,
|
||||||
|
dryRunSuccesses: 0,
|
||||||
|
dryRunObservations: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
),
|
||||||
|
mode: "daily",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => ({
|
||||||
|
ok: false as const,
|
||||||
|
failureCode: "bot_challenge",
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: true,
|
||||||
|
status: 403,
|
||||||
|
durationMs: 80,
|
||||||
|
responseSize: 200,
|
||||||
|
contentType: "text/html",
|
||||||
|
redirectCount: 0,
|
||||||
|
finalUrl: "https://acme.example.com/r/button.json",
|
||||||
|
})),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const bucket = result.state.registries["@acme"].daily[0]
|
||||||
|
expect(bucket.itemObservations).toBe(9)
|
||||||
|
expect(bucket.itemSuccesses).toBe(8)
|
||||||
|
expect(result.run.totals).toMatchObject({
|
||||||
|
challenges: 1,
|
||||||
|
itemChecks: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("runs one rotating weekly dry-run item", async () => {
|
||||||
|
const runDryRun = vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
durationMs: 250,
|
||||||
|
}))
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState: createState(
|
||||||
|
createEntryState({ itemNames: ["button", "dialog"] })
|
||||||
|
),
|
||||||
|
mode: "weekly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(),
|
||||||
|
runDryRun,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(runDryRun).toHaveBeenCalledWith({
|
||||||
|
namespace: "@acme",
|
||||||
|
item: "button",
|
||||||
|
registryUrl: DIRECTORY[0].url,
|
||||||
|
})
|
||||||
|
expect(result.run.totals.dryRuns).toBe(1)
|
||||||
|
expect(result.state.registries["@acme"].recentDryRuns).toHaveLength(1)
|
||||||
|
expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("continues weekly rotation after dry-run history is trimmed", async () => {
|
||||||
|
const runDryRun = vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
durationMs: 250,
|
||||||
|
}))
|
||||||
|
const recentDryRuns = Array.from({ length: 12 }, (_, index) => ({
|
||||||
|
checkedAt: `2026-08-${String(index + 1).padStart(2, "0")}T12:00:00.000Z`,
|
||||||
|
item: index === 11 ? "dialog" : "button",
|
||||||
|
success: true,
|
||||||
|
durationMs: 250,
|
||||||
|
}))
|
||||||
|
|
||||||
|
await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState: createState(
|
||||||
|
createEntryState({
|
||||||
|
itemNames: ["button", "dialog", "tooltip"],
|
||||||
|
recentDryRuns,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
mode: "weekly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(),
|
||||||
|
runDryRun,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(runDryRun).toHaveBeenCalledWith({
|
||||||
|
namespace: "@acme",
|
||||||
|
item: "tooltip",
|
||||||
|
registryUrl: DIRECTORY[0].url,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("runs delayed auto checks using their last run timestamps", async () => {
|
||||||
|
const previousState = {
|
||||||
|
...createState(createEntryState({ itemNames: ["button"] })),
|
||||||
|
lastDailyRunAt: "2026-08-23T15:00:00.000Z",
|
||||||
|
lastWeeklyRunAt: "2026-08-18T12:00:00.000Z",
|
||||||
|
}
|
||||||
|
const fetchJson = vi.fn(async (url: string) =>
|
||||||
|
url.endsWith("/registry.json")
|
||||||
|
? createSuccessfulFetch()
|
||||||
|
: {
|
||||||
|
...createSuccessfulFetch(),
|
||||||
|
json: { name: "item-0", type: "registry:ui", files: [] },
|
||||||
|
finalUrl: url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const runDryRun = vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
durationMs: 250,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState,
|
||||||
|
mode: "auto",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson,
|
||||||
|
runDryRun,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.run.totals.itemChecks).toBe(1)
|
||||||
|
expect(result.run.totals.dryRuns).toBe(1)
|
||||||
|
expect(result.state.lastDailyRunAt).toBe(NOW.toISOString())
|
||||||
|
expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("skips auto checks until their elapsed intervals", async () => {
|
||||||
|
const previousState = {
|
||||||
|
...createState(createEntryState({ itemNames: ["button"] })),
|
||||||
|
lastDailyRunAt: "2026-08-23T17:00:00.000Z",
|
||||||
|
lastWeeklyRunAt: "2026-08-19T12:00:00.000Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState,
|
||||||
|
mode: "auto",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||||
|
runDryRun: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.run.totals.itemChecks).toBe(0)
|
||||||
|
expect(result.run.totals.dryRuns).toBe(0)
|
||||||
|
expect(result.state.lastDailyRunAt).toBe(previousState.lastDailyRunAt)
|
||||||
|
expect(result.state.lastWeeklyRunAt).toBe(previousState.lastWeeklyRunAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("runs auto checks for state without last run timestamps", async () => {
|
||||||
|
const previousState = createState(
|
||||||
|
createEntryState({ itemNames: ["button"] })
|
||||||
|
)
|
||||||
|
const fetchJson = vi.fn(async (url: string) =>
|
||||||
|
url.endsWith("/registry.json")
|
||||||
|
? createSuccessfulFetch()
|
||||||
|
: {
|
||||||
|
...createSuccessfulFetch(),
|
||||||
|
json: { name: "item-0", type: "registry:ui", files: [] },
|
||||||
|
finalUrl: url,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState,
|
||||||
|
mode: "auto",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson,
|
||||||
|
runDryRun: vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
durationMs: 250,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.run.totals.itemChecks).toBe(1)
|
||||||
|
expect(result.run.totals.dryRuns).toBe(1)
|
||||||
|
expect(result.state.lastDailyRunAt).toBe(NOW.toISOString())
|
||||||
|
expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("runs auto checks when last run timestamps are in the future", async () => {
|
||||||
|
const previousState = {
|
||||||
|
...createState(createEntryState({ itemNames: ["button"] })),
|
||||||
|
lastDailyRunAt: "2026-08-25T12:00:00.000Z",
|
||||||
|
lastWeeklyRunAt: "2026-08-25T12:00:00.000Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: DIRECTORY,
|
||||||
|
previousState,
|
||||||
|
mode: "auto",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||||
|
runDryRun: vi.fn(async () => ({
|
||||||
|
success: true,
|
||||||
|
durationMs: 250,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.run.totals.itemChecks).toBe(1)
|
||||||
|
expect(result.run.totals.dryRuns).toBe(1)
|
||||||
|
expect(result.state.lastDailyRunAt).toBe(NOW.toISOString())
|
||||||
|
expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString())
|
||||||
|
})
|
||||||
|
|
||||||
|
it("limits concurrent CLI dry runs to four", async () => {
|
||||||
|
const directory = Array.from({ length: 6 }, (_, index) => ({
|
||||||
|
name: `@registry${index}`,
|
||||||
|
homepage: `https://registry${index}.example.com`,
|
||||||
|
url: `https://registry${index}.example.com/r/{name}.json`,
|
||||||
|
description: `Registry ${index}`,
|
||||||
|
logo: `https://registry${index}.example.com/logo.svg`,
|
||||||
|
}))
|
||||||
|
const previousState: RegistryMonitorState = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
scoreVersion: 1,
|
||||||
|
updatedAt: "2026-08-24T10:00:00.000Z",
|
||||||
|
registries: Object.fromEntries(
|
||||||
|
directory.map((entry) => [
|
||||||
|
entry.name,
|
||||||
|
createEntryState({ itemNames: ["button"] }),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
}
|
||||||
|
let active = 0
|
||||||
|
let maximumActive = 0
|
||||||
|
const runDryRun = vi.fn(async () => {
|
||||||
|
active += 1
|
||||||
|
maximumActive = Math.max(maximumActive, active)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||||
|
active -= 1
|
||||||
|
return { success: true, durationMs: 20 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory,
|
||||||
|
previousState,
|
||||||
|
mode: "weekly",
|
||||||
|
now: NOW,
|
||||||
|
fetchJson: vi.fn(),
|
||||||
|
runDryRun,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.run.totals.dryRuns).toBe(6)
|
||||||
|
expect(maximumActive).toBe(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
import { registryItemSchema, registrySchema } from "shadcn/schema"
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeRegistryName,
|
||||||
|
type RegistryDirectoryEntry,
|
||||||
|
} from "../registry-directory"
|
||||||
|
import { fetchRegistryJson, type RegistryJsonResult } from "./network"
|
||||||
|
import {
|
||||||
|
REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||||
|
REGISTRY_HEALTH_SCORE_VERSION,
|
||||||
|
registryHealthSnapshotSchema,
|
||||||
|
registryMonitorRunSchema,
|
||||||
|
registryMonitorStateSchema,
|
||||||
|
type RegistryDryRunObservation,
|
||||||
|
type RegistryHealthDailyBucket,
|
||||||
|
type RegistryHealthSnapshot,
|
||||||
|
type RegistryIndexObservation,
|
||||||
|
type RegistryItemObservation,
|
||||||
|
type RegistryMonitorEntryState,
|
||||||
|
type RegistryMonitorOutput,
|
||||||
|
type RegistryMonitorRun,
|
||||||
|
type RegistryMonitorState,
|
||||||
|
} from "./schema"
|
||||||
|
import { calculateGlobalMeans, calculateRegistryHealth } from "./score"
|
||||||
|
import { createRegistryMonitorEntryState } from "./state"
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const RECENT_INDEX_RETENTION_MS = 8 * DAY_MS
|
||||||
|
const DAILY_CHECK_INTERVAL_MS = 20 * 60 * 60 * 1000
|
||||||
|
const WEEKLY_CHECK_INTERVAL_MS = 6 * DAY_MS
|
||||||
|
const MAX_DRY_RUN_CONCURRENCY = 4
|
||||||
|
|
||||||
|
export type RegistryMonitorMode = RegistryMonitorRun["mode"]
|
||||||
|
|
||||||
|
export type RegistryDryRunResult = {
|
||||||
|
success: boolean
|
||||||
|
failureCode?: string
|
||||||
|
durationMs: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDailyBucket(date: string) {
|
||||||
|
return {
|
||||||
|
date,
|
||||||
|
availabilitySuccesses: 0,
|
||||||
|
availabilityObservations: 0,
|
||||||
|
challengeObservations: 0,
|
||||||
|
schemaSuccesses: 0,
|
||||||
|
schemaObservations: 0,
|
||||||
|
itemSuccesses: 0,
|
||||||
|
itemObservations: 0,
|
||||||
|
dryRunSuccesses: 0,
|
||||||
|
dryRunObservations: 0,
|
||||||
|
} satisfies RegistryHealthDailyBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDailyBucket(state: RegistryMonitorEntryState, now: Date) {
|
||||||
|
const date = now.toISOString().slice(0, 10)
|
||||||
|
let bucket = state.daily.find((entry) => entry.date === date)
|
||||||
|
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = createDailyBucket(date)
|
||||||
|
state.daily.push(bucket)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimState(state: RegistryMonitorEntryState, now: Date) {
|
||||||
|
state.recentIndex = state.recentIndex
|
||||||
|
.filter(
|
||||||
|
(observation) =>
|
||||||
|
now.getTime() - new Date(observation.checkedAt).getTime() <
|
||||||
|
RECENT_INDEX_RETENTION_MS
|
||||||
|
)
|
||||||
|
.slice(-256)
|
||||||
|
state.recentDryRuns = state.recentDryRuns.slice(-12)
|
||||||
|
state.daily = state.daily
|
||||||
|
.filter(
|
||||||
|
(bucket) =>
|
||||||
|
now.getTime() - new Date(`${bucket.date}T00:00:00.000Z`).getTime() <
|
||||||
|
31 * DAY_MS
|
||||||
|
)
|
||||||
|
.toSorted((a, b) => a.date.localeCompare(b.date))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isJsonMediaType(contentType: string) {
|
||||||
|
const mediaType = contentType.split(";", 1)[0].trim().toLowerCase()
|
||||||
|
return (
|
||||||
|
mediaType === "application/json" ||
|
||||||
|
mediaType === "text/json" ||
|
||||||
|
mediaType.endsWith("+json")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIndexObservation(
|
||||||
|
result: RegistryJsonResult,
|
||||||
|
entry: RegistryDirectoryEntry,
|
||||||
|
now: Date
|
||||||
|
) {
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
observation: {
|
||||||
|
checkedAt: now.toISOString(),
|
||||||
|
outcome: result.botChallenge
|
||||||
|
? ("bot_challenge" as const)
|
||||||
|
: result.reachable
|
||||||
|
? ("reachable" as const)
|
||||||
|
: ("unreachable" as const),
|
||||||
|
status: result.status,
|
||||||
|
failureCode: result.failureCode,
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
responseSize: result.responseSize,
|
||||||
|
redirectCount: result.redirectCount,
|
||||||
|
schemaValid: result.reachable ? false : undefined,
|
||||||
|
contentTypeJson: result.contentType
|
||||||
|
? isJsonMediaType(result.contentType)
|
||||||
|
: undefined,
|
||||||
|
} satisfies RegistryIndexObservation,
|
||||||
|
itemNames: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = registrySchema.safeParse(result.json)
|
||||||
|
const itemNames = parsed.success
|
||||||
|
? parsed.data.items.map((item) => item.name)
|
||||||
|
: null
|
||||||
|
const duplicateNames = itemNames
|
||||||
|
? new Set(itemNames).size !== itemNames.length
|
||||||
|
: undefined
|
||||||
|
const nameMatches = parsed.success
|
||||||
|
? normalizeRegistryName(parsed.data.name) ===
|
||||||
|
normalizeRegistryName(entry.name)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
observation: {
|
||||||
|
checkedAt: now.toISOString(),
|
||||||
|
outcome: "reachable" as const,
|
||||||
|
status: result.status,
|
||||||
|
failureCode: parsed.success ? undefined : "invalid_schema",
|
||||||
|
durationMs: result.durationMs,
|
||||||
|
responseSize: result.responseSize,
|
||||||
|
redirectCount: result.redirectCount,
|
||||||
|
schemaValid: parsed.success,
|
||||||
|
contentTypeJson: isJsonMediaType(result.contentType),
|
||||||
|
duplicateNames,
|
||||||
|
nameMatches,
|
||||||
|
itemCount: itemNames?.length,
|
||||||
|
} satisfies RegistryIndexObservation,
|
||||||
|
itemNames,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordIndexObservation(
|
||||||
|
state: RegistryMonitorEntryState,
|
||||||
|
observation: RegistryIndexObservation,
|
||||||
|
itemNames: string[] | null,
|
||||||
|
now: Date
|
||||||
|
) {
|
||||||
|
state.recentIndex.push(observation)
|
||||||
|
const bucket = getDailyBucket(state, now)
|
||||||
|
|
||||||
|
if (observation.outcome !== "bot_challenge") {
|
||||||
|
if (observation.outcome === "unreachable") {
|
||||||
|
state.consecutiveSuccessfulIndexes = 0
|
||||||
|
state.consecutiveIndexFailures += 1
|
||||||
|
state.availabilityOutageSince ??=
|
||||||
|
state.lastSuccessfulCheck ?? observation.checkedAt
|
||||||
|
const outageDuration =
|
||||||
|
new Date(observation.checkedAt).getTime() -
|
||||||
|
new Date(state.availabilityOutageSince).getTime()
|
||||||
|
const outageRequiresRecovery = outageDuration >= DAY_MS
|
||||||
|
if (state.consecutiveIndexFailures >= 3 || outageRequiresRecovery) {
|
||||||
|
state.availabilityRecoveryRequired = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const outageDuration = state.availabilityOutageSince
|
||||||
|
? new Date(observation.checkedAt).getTime() -
|
||||||
|
new Date(state.availabilityOutageSince).getTime()
|
||||||
|
: 0
|
||||||
|
const outageRequiresRecovery = outageDuration >= DAY_MS
|
||||||
|
state.consecutiveIndexFailures = 0
|
||||||
|
|
||||||
|
if (observation.schemaValid) {
|
||||||
|
state.consecutiveSuccessfulIndexes += 1
|
||||||
|
if (
|
||||||
|
(state.availabilityRecoveryRequired || outageRequiresRecovery) &&
|
||||||
|
state.consecutiveSuccessfulIndexes < 2
|
||||||
|
) {
|
||||||
|
state.availabilityRecoveryRequired = true
|
||||||
|
} else {
|
||||||
|
state.availabilityRecoveryRequired = false
|
||||||
|
delete state.availabilityOutageSince
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.consecutiveSuccessfulIndexes = 0
|
||||||
|
if (state.availabilityRecoveryRequired || outageRequiresRecovery) {
|
||||||
|
state.availabilityRecoveryRequired = true
|
||||||
|
} else {
|
||||||
|
delete state.availabilityOutageSince
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observation.outcome === "bot_challenge") {
|
||||||
|
bucket.challengeObservations += 1
|
||||||
|
} else {
|
||||||
|
bucket.availabilityObservations += 1
|
||||||
|
bucket.availabilitySuccesses += observation.outcome === "reachable" ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observation.outcome === "reachable") {
|
||||||
|
bucket.schemaObservations += 1
|
||||||
|
bucket.schemaSuccesses += observation.schemaValid ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observation.outcome === "reachable") {
|
||||||
|
state.latestHygiene = {
|
||||||
|
contentTypeJson: observation.contentTypeJson ?? false,
|
||||||
|
noDuplicateNames:
|
||||||
|
observation.duplicateNames === undefined
|
||||||
|
? null
|
||||||
|
: !observation.duplicateNames,
|
||||||
|
nameMatches: observation.nameMatches ?? null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (observation.schemaValid && itemNames) {
|
||||||
|
state.itemNames = itemNames
|
||||||
|
state.lastSuccessfulCheck = observation.checkedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getItemNamesForDailyCheck(state: RegistryMonitorEntryState) {
|
||||||
|
if (state.itemNames.length === 0) return []
|
||||||
|
|
||||||
|
const count = Math.max(1, Math.ceil(state.itemNames.length / 30))
|
||||||
|
const selected = Array.from({ length: count }, (_, offset) => {
|
||||||
|
const index = (state.itemCursor + offset) % state.itemNames.length
|
||||||
|
return state.itemNames[index]
|
||||||
|
})
|
||||||
|
state.itemCursor = (state.itemCursor + count) % state.itemNames.length
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordItemObservation(
|
||||||
|
state: RegistryMonitorEntryState,
|
||||||
|
observation: RegistryItemObservation,
|
||||||
|
now: Date
|
||||||
|
) {
|
||||||
|
if (observation.failureCode === "bot_challenge") return
|
||||||
|
|
||||||
|
const bucket = getDailyBucket(state, now)
|
||||||
|
bucket.itemObservations += 1
|
||||||
|
bucket.itemSuccesses += observation.success ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNextDryRunItem(state: RegistryMonitorEntryState) {
|
||||||
|
if (state.itemNames.length === 0) return null
|
||||||
|
|
||||||
|
const previousItem = state.recentDryRuns.at(-1)?.item
|
||||||
|
const previousIndex = previousItem
|
||||||
|
? state.itemNames.indexOf(previousItem)
|
||||||
|
: -1
|
||||||
|
|
||||||
|
return state.itemNames[(previousIndex + 1) % state.itemNames.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordDryRunObservation(
|
||||||
|
state: RegistryMonitorEntryState,
|
||||||
|
observation: RegistryDryRunObservation,
|
||||||
|
now: Date
|
||||||
|
) {
|
||||||
|
state.recentDryRuns.push(observation)
|
||||||
|
const bucket = getDailyBucket(state, now)
|
||||||
|
bucket.dryRunObservations += 1
|
||||||
|
bucket.dryRunSuccesses += observation.success ? 1 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceRegistryItem(url: string, item: string) {
|
||||||
|
return url.replace("{name}", item)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasIntervalElapsed(
|
||||||
|
lastRunAt: string | undefined,
|
||||||
|
now: Date,
|
||||||
|
intervalMs: number
|
||||||
|
) {
|
||||||
|
if (!lastRunAt) return true
|
||||||
|
|
||||||
|
const elapsed = now.getTime() - new Date(lastRunAt).getTime()
|
||||||
|
return elapsed < 0 || elapsed >= intervalMs
|
||||||
|
}
|
||||||
|
|
||||||
|
function getChecks(
|
||||||
|
mode: RegistryMonitorMode,
|
||||||
|
now: Date,
|
||||||
|
previousState?: RegistryMonitorState | null
|
||||||
|
) {
|
||||||
|
if (mode === "all") {
|
||||||
|
return { index: true, items: true, dryRun: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode !== "auto") {
|
||||||
|
return {
|
||||||
|
index: mode === "hourly",
|
||||||
|
items: mode === "daily",
|
||||||
|
dryRun: mode === "weekly",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
index: true,
|
||||||
|
items: hasIntervalElapsed(
|
||||||
|
previousState?.lastDailyRunAt,
|
||||||
|
now,
|
||||||
|
DAILY_CHECK_INTERVAL_MS
|
||||||
|
),
|
||||||
|
dryRun: hasIntervalElapsed(
|
||||||
|
previousState?.lastWeeklyRunAt,
|
||||||
|
now,
|
||||||
|
WEEKLY_CHECK_INTERVAL_MS
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConcurrencyLimit(concurrency: number) {
|
||||||
|
let active = 0
|
||||||
|
const waiting: Array<() => void> = []
|
||||||
|
|
||||||
|
return async function limit<T>(task: () => Promise<T>) {
|
||||||
|
while (active >= concurrency) {
|
||||||
|
await new Promise<void>((resolve) => waiting.push(resolve))
|
||||||
|
}
|
||||||
|
|
||||||
|
active += 1
|
||||||
|
try {
|
||||||
|
return await task()
|
||||||
|
} finally {
|
||||||
|
active -= 1
|
||||||
|
waiting.shift()?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mapWithConcurrency<T>(
|
||||||
|
values: T[],
|
||||||
|
concurrency: number,
|
||||||
|
task: (value: T) => Promise<void>
|
||||||
|
) {
|
||||||
|
let cursor = 0
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (cursor < values.length) {
|
||||||
|
const index = cursor
|
||||||
|
cursor += 1
|
||||||
|
await task(values[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(concurrency, values.length) }, () => worker())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByHost(directory: RegistryDirectoryEntry[]) {
|
||||||
|
const groups = new Map<string, RegistryDirectoryEntry[]>()
|
||||||
|
|
||||||
|
for (const entry of directory) {
|
||||||
|
let host = entry.name
|
||||||
|
try {
|
||||||
|
host = new URL(entry.url).host
|
||||||
|
} catch {
|
||||||
|
host = entry.name
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = groups.get(host) ?? []
|
||||||
|
group.push(entry)
|
||||||
|
groups.set(host, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runRegistryMonitor({
|
||||||
|
directory,
|
||||||
|
previousState,
|
||||||
|
mode = "auto",
|
||||||
|
now = new Date(),
|
||||||
|
fetchJson = fetchRegistryJson,
|
||||||
|
runDryRun,
|
||||||
|
concurrency = 12,
|
||||||
|
}: {
|
||||||
|
directory: RegistryDirectoryEntry[]
|
||||||
|
previousState?: RegistryMonitorState | null
|
||||||
|
mode?: RegistryMonitorMode
|
||||||
|
now?: Date
|
||||||
|
fetchJson?: typeof fetchRegistryJson
|
||||||
|
runDryRun: (options: {
|
||||||
|
namespace: string
|
||||||
|
item: string
|
||||||
|
registryUrl: string
|
||||||
|
}) => Promise<RegistryDryRunResult>
|
||||||
|
concurrency?: number
|
||||||
|
}): Promise<RegistryMonitorOutput> {
|
||||||
|
const startedAt = now.toISOString()
|
||||||
|
const checks = getChecks(mode, now, previousState)
|
||||||
|
const state: RegistryMonitorState = {
|
||||||
|
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||||
|
scoreVersion: REGISTRY_HEALTH_SCORE_VERSION,
|
||||||
|
updatedAt: now.toISOString(),
|
||||||
|
...(previousState?.lastDailyRunAt
|
||||||
|
? { lastDailyRunAt: previousState.lastDailyRunAt }
|
||||||
|
: {}),
|
||||||
|
...(previousState?.lastWeeklyRunAt
|
||||||
|
? { lastWeeklyRunAt: previousState.lastWeeklyRunAt }
|
||||||
|
: {}),
|
||||||
|
registries: {},
|
||||||
|
}
|
||||||
|
const limitDryRun = createConcurrencyLimit(MAX_DRY_RUN_CONCURRENCY)
|
||||||
|
const runResults: RegistryMonitorRun["results"] = {}
|
||||||
|
const totals = {
|
||||||
|
registries: directory.length,
|
||||||
|
reachable: 0,
|
||||||
|
unavailable: 0,
|
||||||
|
challenges: 0,
|
||||||
|
itemChecks: 0,
|
||||||
|
dryRuns: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of directory) {
|
||||||
|
state.registries[entry.name] = structuredClone(
|
||||||
|
previousState?.registries[entry.name] ??
|
||||||
|
createRegistryMonitorEntryState(now)
|
||||||
|
)
|
||||||
|
runResults[entry.name] = { items: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
await mapWithConcurrency(
|
||||||
|
groupByHost(directory),
|
||||||
|
concurrency,
|
||||||
|
async (group) => {
|
||||||
|
for (const entry of group) {
|
||||||
|
const entryState = state.registries[entry.name]
|
||||||
|
const result = runResults[entry.name]
|
||||||
|
|
||||||
|
if (checks.index) {
|
||||||
|
const indexResult = await fetchJson(
|
||||||
|
replaceRegistryItem(entry.url, "registry")
|
||||||
|
)
|
||||||
|
const { observation, itemNames } = getIndexObservation(
|
||||||
|
indexResult,
|
||||||
|
entry,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
recordIndexObservation(entryState, observation, itemNames, now)
|
||||||
|
result.index = observation
|
||||||
|
|
||||||
|
if (observation.outcome === "reachable") totals.reachable += 1
|
||||||
|
if (observation.outcome === "unreachable") totals.unavailable += 1
|
||||||
|
if (observation.outcome === "bot_challenge") totals.challenges += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checks.items) {
|
||||||
|
for (const item of getItemNamesForDailyCheck(entryState)) {
|
||||||
|
const itemResult = await fetchJson(
|
||||||
|
replaceRegistryItem(entry.url, item)
|
||||||
|
)
|
||||||
|
const parsed = itemResult.ok
|
||||||
|
? registryItemSchema.safeParse(itemResult.json)
|
||||||
|
: null
|
||||||
|
const success = !!(parsed?.success && parsed.data.name === item)
|
||||||
|
const observation: RegistryItemObservation = {
|
||||||
|
checkedAt: now.toISOString(),
|
||||||
|
item,
|
||||||
|
success,
|
||||||
|
failureCode: success
|
||||||
|
? undefined
|
||||||
|
: itemResult.ok
|
||||||
|
? parsed?.success
|
||||||
|
? "name_mismatch"
|
||||||
|
: "invalid_schema"
|
||||||
|
: itemResult.failureCode,
|
||||||
|
durationMs: itemResult.durationMs,
|
||||||
|
}
|
||||||
|
recordItemObservation(entryState, observation, now)
|
||||||
|
result.items.push(observation)
|
||||||
|
totals.itemChecks += 1
|
||||||
|
if (!itemResult.ok && itemResult.botChallenge) {
|
||||||
|
totals.challenges += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dryRunItem = checks.dryRun ? getNextDryRunItem(entryState) : null
|
||||||
|
if (dryRunItem) {
|
||||||
|
const dryRunResult = await limitDryRun(() =>
|
||||||
|
runDryRun({
|
||||||
|
namespace: entry.name,
|
||||||
|
item: dryRunItem,
|
||||||
|
registryUrl: entry.url,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const observation: RegistryDryRunObservation = {
|
||||||
|
checkedAt: now.toISOString(),
|
||||||
|
item: dryRunItem,
|
||||||
|
...dryRunResult,
|
||||||
|
}
|
||||||
|
recordDryRunObservation(entryState, observation, now)
|
||||||
|
result.dryRun = observation
|
||||||
|
totals.dryRuns += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trimState(entryState, now)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if (checks.items) {
|
||||||
|
state.lastDailyRunAt = now.toISOString()
|
||||||
|
}
|
||||||
|
if (checks.dryRun) {
|
||||||
|
state.lastWeeklyRunAt = now.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryUrls = Object.fromEntries(
|
||||||
|
directory.map((entry) => [entry.name, entry.url])
|
||||||
|
)
|
||||||
|
const globalMeans = calculateGlobalMeans(state, registryUrls, now)
|
||||||
|
const snapshot: RegistryHealthSnapshot = {
|
||||||
|
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||||
|
scoreVersion: REGISTRY_HEALTH_SCORE_VERSION,
|
||||||
|
generatedAt: now.toISOString(),
|
||||||
|
globalMeans,
|
||||||
|
registries: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of directory) {
|
||||||
|
const entryState = state.registries[entry.name]
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state: entryState,
|
||||||
|
registryUrl: entry.url,
|
||||||
|
globalMeans,
|
||||||
|
now,
|
||||||
|
})
|
||||||
|
entryState.status = health.status
|
||||||
|
snapshot.registries[entry.name] = health
|
||||||
|
}
|
||||||
|
|
||||||
|
const run: RegistryMonitorRun = {
|
||||||
|
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||||
|
startedAt,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
mode,
|
||||||
|
totals,
|
||||||
|
results: runResults,
|
||||||
|
diagnostics: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: registryMonitorStateSchema.parse(state),
|
||||||
|
snapshot: registryHealthSnapshotSchema.parse(snapshot),
|
||||||
|
run: registryMonitorRunSchema.parse(run),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
createGuardedConnector,
|
||||||
|
detectBotChallenge,
|
||||||
|
fetchRegistryJson,
|
||||||
|
isForbiddenAddress,
|
||||||
|
validateRegistryUrl,
|
||||||
|
} from "./network"
|
||||||
|
|
||||||
|
describe("network destination safety", () => {
|
||||||
|
it.each([
|
||||||
|
"127.0.0.1",
|
||||||
|
"10.0.0.1",
|
||||||
|
"168.63.129.16",
|
||||||
|
"169.254.169.254",
|
||||||
|
"192.0.2.1",
|
||||||
|
"192.88.99.1",
|
||||||
|
"192.168.1.1",
|
||||||
|
"::1",
|
||||||
|
"64:ff9b::7f00:1",
|
||||||
|
"100::",
|
||||||
|
"2001::1",
|
||||||
|
"2002:7f00:1::",
|
||||||
|
"fe80::1",
|
||||||
|
"fec0::1",
|
||||||
|
"fd00:ec2::254",
|
||||||
|
"::ffff:127.0.0.1",
|
||||||
|
])("rejects non-public address %s", (address) => {
|
||||||
|
expect(isForbiddenAddress(address)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"])(
|
||||||
|
"allows public address %s",
|
||||||
|
(address) => {
|
||||||
|
expect(isForbiddenAddress(address)).toBe(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it("rejects unsupported protocols and embedded credentials", () => {
|
||||||
|
expect(() => validateRegistryUrl("file:///etc/passwd")).toThrow(
|
||||||
|
"unsupported_protocol"
|
||||||
|
)
|
||||||
|
expect(() =>
|
||||||
|
validateRegistryUrl("https://user:secret@example.com/registry.json")
|
||||||
|
).toThrow("embedded_credentials")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("pins the validated address used by the socket connector", async () => {
|
||||||
|
const connect = vi.fn((_options, callback) => callback(null, {}))
|
||||||
|
const connector = createGuardedConnector({
|
||||||
|
resolve: async () => [{ address: "203.0.114.10", family: 4 }],
|
||||||
|
connect,
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
connector(
|
||||||
|
{
|
||||||
|
hostname: "registry.example.com",
|
||||||
|
protocol: "https:",
|
||||||
|
port: "443",
|
||||||
|
},
|
||||||
|
(error, _socket) => (error ? reject(error) : resolve())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(connect).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
hostname: "203.0.114.10",
|
||||||
|
servername: "registry.example.com",
|
||||||
|
}),
|
||||||
|
expect.any(Function)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rejects a hostname when DNS includes a private address", async () => {
|
||||||
|
const connector = createGuardedConnector({
|
||||||
|
resolve: async () => [
|
||||||
|
{ address: "203.0.114.10", family: 4 },
|
||||||
|
{ address: "127.0.0.1", family: 4 },
|
||||||
|
],
|
||||||
|
connect: vi.fn(),
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
connector(
|
||||||
|
{
|
||||||
|
hostname: "registry.example.com",
|
||||||
|
protocol: "https:",
|
||||||
|
port: "443",
|
||||||
|
},
|
||||||
|
(error, _socket) => (error ? reject(error) : resolve())
|
||||||
|
)
|
||||||
|
})
|
||||||
|
).rejects.toThrow("forbidden_address")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("fetchRegistryJson", () => {
|
||||||
|
const dispatcher = {
|
||||||
|
close: vi.fn(async () => {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
it("validates every manual redirect target", async () => {
|
||||||
|
const fetchImpl = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: "http://127.0.0.1/registry.json" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
fetchRegistryJson("https://example.com/registry.json", {
|
||||||
|
fetchImpl,
|
||||||
|
createDispatcher: () => dispatcher as never,
|
||||||
|
attempts: 1,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
failureCode: "forbidden_address",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shares one timeout budget across redirects", async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
vi.setSystemTime(new Date("2026-08-24T12:00:00.000Z"))
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async () => {
|
||||||
|
vi.setSystemTime(new Date("2026-08-24T12:00:00.080Z"))
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { location: "https://example.com/final.json" },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(
|
||||||
|
async (_url: string, init: RequestInit) =>
|
||||||
|
new Promise<Response>((_resolve, reject) => {
|
||||||
|
init.signal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const resultPromise = fetchRegistryJson(
|
||||||
|
"https://example.com/registry.json",
|
||||||
|
{
|
||||||
|
fetchImpl,
|
||||||
|
createDispatcher: () => dispatcher as never,
|
||||||
|
attempts: 1,
|
||||||
|
timeoutMs: 100,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(20)
|
||||||
|
|
||||||
|
await expect(resultPromise).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
failureCode: "timeout",
|
||||||
|
})
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("retries a retryable response and honors Retry-After", async () => {
|
||||||
|
const wait = vi.fn(async () => {})
|
||||||
|
const fetchImpl = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response("busy", {
|
||||||
|
status: 429,
|
||||||
|
headers: { "retry-after": "1" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
new Response('{"name":"acme"}', {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const result = await fetchRegistryJson(
|
||||||
|
"https://example.com/registry.json",
|
||||||
|
{
|
||||||
|
fetchImpl,
|
||||||
|
createDispatcher: () => dispatcher as never,
|
||||||
|
wait,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||||
|
expect(wait).toHaveBeenCalledWith(1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("enforces the response-size limit", async () => {
|
||||||
|
const result = await fetchRegistryJson(
|
||||||
|
"https://example.com/registry.json",
|
||||||
|
{
|
||||||
|
fetchImpl: async () => new Response("too large"),
|
||||||
|
createDispatcher: () => dispatcher as never,
|
||||||
|
maximumBytes: 3,
|
||||||
|
attempts: 1,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
failureCode: "body_too_large",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("detectBotChallenge", () => {
|
||||||
|
it("recognizes a provider challenge page", () => {
|
||||||
|
expect(
|
||||||
|
detectBotChallenge({
|
||||||
|
status: 403,
|
||||||
|
contentType: "text/html",
|
||||||
|
server: "cloudflare",
|
||||||
|
body: "<title>Just a moment...</title><div class='cf-chl-test'>",
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not classify an ordinary JSON 403 as a challenge", () => {
|
||||||
|
expect(
|
||||||
|
detectBotChallenge({
|
||||||
|
status: 403,
|
||||||
|
contentType: "application/json",
|
||||||
|
server: "",
|
||||||
|
body: '{"error":"forbidden"}',
|
||||||
|
})
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,586 @@
|
|||||||
|
import { lookup } from "node:dns/promises"
|
||||||
|
import { isIP } from "node:net"
|
||||||
|
import {
|
||||||
|
Agent,
|
||||||
|
buildConnector,
|
||||||
|
fetch as undiciFetch,
|
||||||
|
type Dispatcher,
|
||||||
|
} from "undici"
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT_MS = 10_000
|
||||||
|
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
|
||||||
|
const DEFAULT_MAX_REDIRECTS = 5
|
||||||
|
const MAX_RETRY_AFTER_MS = 30_000
|
||||||
|
|
||||||
|
type ResolvedAddress = {
|
||||||
|
address: string
|
||||||
|
family: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolveHostname = (hostname: string) => Promise<ResolvedAddress[]>
|
||||||
|
type Connector = ReturnType<typeof buildConnector>
|
||||||
|
|
||||||
|
type FetchLike = (
|
||||||
|
url: string,
|
||||||
|
init: RequestInit & { dispatcher?: Dispatcher }
|
||||||
|
) => Promise<Response>
|
||||||
|
|
||||||
|
export type RegistryJsonResult =
|
||||||
|
| {
|
||||||
|
ok: true
|
||||||
|
json: unknown
|
||||||
|
status: number
|
||||||
|
durationMs: number
|
||||||
|
responseSize: number
|
||||||
|
contentType: string
|
||||||
|
redirectCount: number
|
||||||
|
finalUrl: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: false
|
||||||
|
failureCode: string
|
||||||
|
reachable: boolean
|
||||||
|
botChallenge: boolean
|
||||||
|
status?: number
|
||||||
|
durationMs: number
|
||||||
|
responseSize?: number
|
||||||
|
contentType?: string
|
||||||
|
redirectCount: number
|
||||||
|
finalUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegistryJsonAttemptResult =
|
||||||
|
| Extract<RegistryJsonResult, { ok: true }>
|
||||||
|
| (Extract<RegistryJsonResult, { ok: false }> & {
|
||||||
|
retryAfterMs?: number
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseIpv4(address: string) {
|
||||||
|
if (isIP(address) !== 4) return null
|
||||||
|
|
||||||
|
return address.split(".").map(Number)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv6(address: string) {
|
||||||
|
if (address.includes("%") || isIP(address) !== 6) return null
|
||||||
|
|
||||||
|
let value = address.toLowerCase()
|
||||||
|
const ipv4Match = value.match(/(\d+\.\d+\.\d+\.\d+)$/)
|
||||||
|
|
||||||
|
if (ipv4Match) {
|
||||||
|
const ipv4 = parseIpv4(ipv4Match[1])
|
||||||
|
if (!ipv4) return null
|
||||||
|
value = value.replace(
|
||||||
|
ipv4Match[1],
|
||||||
|
`${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${(
|
||||||
|
(ipv4[2] << 8) |
|
||||||
|
ipv4[3]
|
||||||
|
).toString(16)}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [left = "", right = ""] = value.split("::")
|
||||||
|
const leftParts = left ? left.split(":") : []
|
||||||
|
const rightParts = right ? right.split(":") : []
|
||||||
|
const missing = 8 - leftParts.length - rightParts.length
|
||||||
|
const parts = value.includes("::")
|
||||||
|
? [
|
||||||
|
...leftParts,
|
||||||
|
...Array.from({ length: missing }, () => "0"),
|
||||||
|
...rightParts,
|
||||||
|
]
|
||||||
|
: leftParts
|
||||||
|
|
||||||
|
if (parts.length !== 8) return null
|
||||||
|
|
||||||
|
const parsed = parts.map((part) => Number.parseInt(part || "0", 16))
|
||||||
|
return parsed.every((part) => Number.isInteger(part) && part <= 0xffff)
|
||||||
|
? parsed
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenIpv4(address: string) {
|
||||||
|
const parts = parseIpv4(address)
|
||||||
|
if (!parts) return true
|
||||||
|
|
||||||
|
const [a, b, c] = parts
|
||||||
|
|
||||||
|
return (
|
||||||
|
a === 0 ||
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 100 && b >= 64 && b <= 127) ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && b === 0 && c === 0) ||
|
||||||
|
(a === 192 && b === 0 && c === 2) ||
|
||||||
|
(a === 192 && b === 88 && c === 99) ||
|
||||||
|
(a === 192 && b === 168) ||
|
||||||
|
(a === 198 && (b === 18 || b === 19)) ||
|
||||||
|
(a === 198 && b === 51 && c === 100) ||
|
||||||
|
(a === 203 && b === 0 && c === 113) ||
|
||||||
|
(a === 168 && b === 63 && c === 129 && parts[3] === 16) ||
|
||||||
|
a >= 224
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenIpv6(address: string) {
|
||||||
|
const parts = parseIpv6(address)
|
||||||
|
if (!parts) return true
|
||||||
|
|
||||||
|
const isUnspecified = parts.every((part) => part === 0)
|
||||||
|
const isLoopback =
|
||||||
|
parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1
|
||||||
|
const isUniqueLocal = (parts[0] & 0xfe00) === 0xfc00
|
||||||
|
const isLinkLocal = (parts[0] & 0xffc0) === 0xfe80
|
||||||
|
const isSiteLocal = (parts[0] & 0xffc0) === 0xfec0
|
||||||
|
const isMulticast = (parts[0] & 0xff00) === 0xff00
|
||||||
|
const isDocumentation = parts[0] === 0x2001 && parts[1] === 0x0db8
|
||||||
|
const isProtocolAssignment =
|
||||||
|
parts[0] === 0x2001 && parts[1] >= 0 && parts[1] <= 0x01ff
|
||||||
|
const isSixToFour = parts[0] === 0x2002
|
||||||
|
const isDiscardOnly =
|
||||||
|
parts[0] === 0x0100 && parts.slice(1, 4).every((part) => part === 0)
|
||||||
|
const isNat64 =
|
||||||
|
(parts[0] === 0x0064 &&
|
||||||
|
parts[1] === 0xff9b &&
|
||||||
|
parts.slice(2, 6).every((part) => part === 0)) ||
|
||||||
|
(parts[0] === 0x0064 && parts[1] === 0xff9b && parts[2] === 0x0001)
|
||||||
|
const isIpv4Mapped =
|
||||||
|
parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff
|
||||||
|
const isIpv4Compatible = parts.slice(0, 6).every((part) => part === 0)
|
||||||
|
|
||||||
|
if (isIpv4Mapped) {
|
||||||
|
return isForbiddenIpv4(
|
||||||
|
`${parts[6] >> 8}.${parts[6] & 255}.${parts[7] >> 8}.${parts[7] & 255}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
isUnspecified ||
|
||||||
|
isLoopback ||
|
||||||
|
isUniqueLocal ||
|
||||||
|
isLinkLocal ||
|
||||||
|
isSiteLocal ||
|
||||||
|
isMulticast ||
|
||||||
|
isDocumentation ||
|
||||||
|
isProtocolAssignment ||
|
||||||
|
isSixToFour ||
|
||||||
|
isDiscardOnly ||
|
||||||
|
isNat64 ||
|
||||||
|
isIpv4Compatible
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isForbiddenAddress(address: string) {
|
||||||
|
const normalized = address.replace(/^\[|\]$/g, "")
|
||||||
|
const family = isIP(normalized)
|
||||||
|
|
||||||
|
if (family === 4) return isForbiddenIpv4(normalized)
|
||||||
|
if (family === 6) return isForbiddenIpv6(normalized)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRegistryUrl(input: string) {
|
||||||
|
const url = new URL(input)
|
||||||
|
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||||
|
throw new Error("unsupported_protocol")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.username || url.password) {
|
||||||
|
throw new Error("embedded_credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = url.hostname.replace(/^\[|\]$/g, "")
|
||||||
|
if (isIP(hostname) && isForbiddenAddress(hostname)) {
|
||||||
|
throw new Error("forbidden_address")
|
||||||
|
}
|
||||||
|
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultResolve: ResolveHostname = (hostname) =>
|
||||||
|
lookup(hostname, { all: true, verbatim: true })
|
||||||
|
|
||||||
|
export function createGuardedConnector({
|
||||||
|
resolve = defaultResolve,
|
||||||
|
connect = buildConnector({ timeout: DEFAULT_TIMEOUT_MS }),
|
||||||
|
}: {
|
||||||
|
resolve?: ResolveHostname
|
||||||
|
connect?: Connector
|
||||||
|
} = {}): Connector {
|
||||||
|
return (options, callback) => {
|
||||||
|
const hostname = options.hostname.replace(/^\[|\]$/g, "")
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
const addresses = isIP(hostname)
|
||||||
|
? [{ address: hostname, family: isIP(hostname) }]
|
||||||
|
: await resolve(hostname)
|
||||||
|
|
||||||
|
if (
|
||||||
|
addresses.length === 0 ||
|
||||||
|
addresses.some(({ address }) => isForbiddenAddress(address))
|
||||||
|
) {
|
||||||
|
throw new Error("forbidden_address")
|
||||||
|
}
|
||||||
|
|
||||||
|
const [{ address }] = addresses
|
||||||
|
connect(
|
||||||
|
{
|
||||||
|
...options,
|
||||||
|
hostname: address,
|
||||||
|
servername: options.servername ?? hostname,
|
||||||
|
},
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
})().catch((error) =>
|
||||||
|
callback(error instanceof Error ? error : new Error("dns_failure"), null)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGuardedDispatcher() {
|
||||||
|
return new Agent({
|
||||||
|
connect: createGuardedConnector(),
|
||||||
|
headersTimeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
bodyTimeout: DEFAULT_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readLimitedBody(response: Response, maximumBytes: number) {
|
||||||
|
if (!response.body) return { text: "", size: 0 }
|
||||||
|
|
||||||
|
const reader = response.body.getReader()
|
||||||
|
const chunks: Uint8Array[] = []
|
||||||
|
let size = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
size += value.byteLength
|
||||||
|
|
||||||
|
if (size > maximumBytes) {
|
||||||
|
await reader.cancel()
|
||||||
|
throw new Error("body_too_large")
|
||||||
|
}
|
||||||
|
|
||||||
|
chunks.push(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = new Uint8Array(size)
|
||||||
|
let offset = 0
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
body.set(chunk, offset)
|
||||||
|
offset += chunk.byteLength
|
||||||
|
}
|
||||||
|
|
||||||
|
return { text: new TextDecoder().decode(body), size }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function detectBotChallenge({
|
||||||
|
status,
|
||||||
|
contentType,
|
||||||
|
server,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
status: number
|
||||||
|
contentType: string
|
||||||
|
server: string
|
||||||
|
body: string
|
||||||
|
}) {
|
||||||
|
const content = body.toLowerCase()
|
||||||
|
const provider = server.toLowerCase()
|
||||||
|
const isHtml = contentType.toLowerCase().includes("text/html")
|
||||||
|
const marker = [
|
||||||
|
"cf-chl-",
|
||||||
|
"challenge-platform",
|
||||||
|
"just a moment",
|
||||||
|
"attention required",
|
||||||
|
"vercel security checkpoint",
|
||||||
|
"captcha",
|
||||||
|
].some((value) => content.includes(value))
|
||||||
|
|
||||||
|
return (
|
||||||
|
marker &&
|
||||||
|
(isHtml || status === 403 || status === 429) &&
|
||||||
|
(provider.includes("cloudflare") ||
|
||||||
|
provider.includes("vercel") ||
|
||||||
|
content.includes("challenge") ||
|
||||||
|
content.includes("captcha"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRedirect(status: number) {
|
||||||
|
return [301, 302, 303, 307, 308].includes(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRetryableStatus(status: number) {
|
||||||
|
return status === 408 || status === 429 || status >= 500
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRetryAfter(value: string | null, now: number) {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const seconds = Number(value)
|
||||||
|
const delay = Number.isFinite(seconds)
|
||||||
|
? seconds * 1000
|
||||||
|
: new Date(value).getTime() - now
|
||||||
|
|
||||||
|
if (!Number.isFinite(delay) || delay < 0) return null
|
||||||
|
return Math.min(delay, MAX_RETRY_AFTER_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(milliseconds: number) {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnce(
|
||||||
|
input: string,
|
||||||
|
{
|
||||||
|
timeoutMs,
|
||||||
|
maximumBytes,
|
||||||
|
maximumRedirects,
|
||||||
|
fetchImpl,
|
||||||
|
createDispatcher,
|
||||||
|
}: {
|
||||||
|
timeoutMs: number
|
||||||
|
maximumBytes: number
|
||||||
|
maximumRedirects: number
|
||||||
|
fetchImpl: FetchLike
|
||||||
|
createDispatcher: () => Dispatcher
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const startedAt = Date.now()
|
||||||
|
let currentUrl = validateRegistryUrl(input)
|
||||||
|
let redirectCount = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
validateRegistryUrl(currentUrl.toString())
|
||||||
|
const remainingMs = timeoutMs - (Date.now() - startedAt)
|
||||||
|
if (remainingMs <= 0) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failureCode: "timeout",
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: false,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatcher = createDispatcher()
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeout = setTimeout(() => controller.abort(), remainingMs)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchImpl(currentUrl.toString(), {
|
||||||
|
dispatcher,
|
||||||
|
redirect: "manual",
|
||||||
|
signal: controller.signal,
|
||||||
|
headers: {
|
||||||
|
Accept: "application/json",
|
||||||
|
"User-Agent": "shadcn-registry-health/1.0",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isRedirect(response.status)) {
|
||||||
|
const location = response.headers.get("location")
|
||||||
|
await response.body?.cancel()
|
||||||
|
if (!location) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failureCode: "redirect_without_location",
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: false,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectCount += 1
|
||||||
|
if (redirectCount > maximumRedirects) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failureCode: "too_many_redirects",
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: false,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUrl = validateRegistryUrl(
|
||||||
|
new URL(location, currentUrl).toString()
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: Awaited<ReturnType<typeof readLimitedBody>>
|
||||||
|
try {
|
||||||
|
body = await readLimitedBody(response, maximumBytes)
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failureCode: "body_too_large",
|
||||||
|
reachable: response.ok,
|
||||||
|
botChallenge: false,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") ?? ""
|
||||||
|
const botChallenge = detectBotChallenge({
|
||||||
|
status: response.status,
|
||||||
|
contentType,
|
||||||
|
server: response.headers.get("server") ?? "",
|
||||||
|
body: body.text,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (botChallenge) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failureCode: "bot_challenge",
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: true,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
responseSize: body.size,
|
||||||
|
contentType,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failureCode: `http_${response.status}`,
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: false,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
responseSize: body.size,
|
||||||
|
contentType,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
retryAfterMs:
|
||||||
|
parseRetryAfter(response.headers.get("retry-after"), Date.now()) ??
|
||||||
|
undefined,
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: JSON.parse(body.text),
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
responseSize: body.size,
|
||||||
|
contentType,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failureCode: "invalid_json",
|
||||||
|
reachable: true,
|
||||||
|
botChallenge: false,
|
||||||
|
status: response.status,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
responseSize: body.size,
|
||||||
|
contentType,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const messages = [
|
||||||
|
error instanceof Error ? error.message : "",
|
||||||
|
error instanceof Error && error.cause instanceof Error
|
||||||
|
? error.cause.message
|
||||||
|
: "",
|
||||||
|
]
|
||||||
|
const guardedFailure = [
|
||||||
|
"embedded_credentials",
|
||||||
|
"forbidden_address",
|
||||||
|
"unsupported_protocol",
|
||||||
|
].find((failureCode) => messages.includes(failureCode))
|
||||||
|
const failureCode =
|
||||||
|
guardedFailure ??
|
||||||
|
(error instanceof Error && error.name === "AbortError"
|
||||||
|
? "timeout"
|
||||||
|
: "transport_error")
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
failureCode,
|
||||||
|
reachable: false,
|
||||||
|
botChallenge: false,
|
||||||
|
durationMs: Date.now() - startedAt,
|
||||||
|
redirectCount,
|
||||||
|
finalUrl: currentUrl.toString(),
|
||||||
|
} satisfies RegistryJsonAttemptResult
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
await dispatcher.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchRegistryJson(
|
||||||
|
input: string,
|
||||||
|
options: {
|
||||||
|
attempts?: number
|
||||||
|
timeoutMs?: number
|
||||||
|
maximumBytes?: number
|
||||||
|
maximumRedirects?: number
|
||||||
|
fetchImpl?: FetchLike
|
||||||
|
createDispatcher?: () => Dispatcher
|
||||||
|
wait?: (milliseconds: number) => Promise<unknown>
|
||||||
|
random?: () => number
|
||||||
|
} = {}
|
||||||
|
): Promise<RegistryJsonResult> {
|
||||||
|
const attempts = options.attempts ?? 3
|
||||||
|
const wait = options.wait ?? sleep
|
||||||
|
const random = options.random ?? Math.random
|
||||||
|
let result: Awaited<ReturnType<typeof fetchOnce>> | undefined
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
|
result = await fetchOnce(input, {
|
||||||
|
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||||
|
maximumBytes: options.maximumBytes ?? DEFAULT_MAX_BODY_BYTES,
|
||||||
|
maximumRedirects: options.maximumRedirects ?? DEFAULT_MAX_REDIRECTS,
|
||||||
|
fetchImpl: options.fetchImpl ?? (undiciFetch as unknown as FetchLike),
|
||||||
|
createDispatcher: options.createDispatcher ?? createGuardedDispatcher,
|
||||||
|
})
|
||||||
|
|
||||||
|
const retryable =
|
||||||
|
!result.ok &&
|
||||||
|
!result.botChallenge &&
|
||||||
|
(result.failureCode === "transport_error" ||
|
||||||
|
result.failureCode === "timeout" ||
|
||||||
|
(result.status !== undefined && isRetryableStatus(result.status)))
|
||||||
|
|
||||||
|
if (!retryable || attempt === attempts - 1) {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
await wait(
|
||||||
|
result.retryAfterMs ??
|
||||||
|
Math.min(1000 * 2 ** attempt + random() * 250, 5000)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result!
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { registryMonitorEntryStateSchema } from "./schema"
|
||||||
|
|
||||||
|
describe("registryMonitorEntryStateSchema", () => {
|
||||||
|
it("migrates legacy availability recovery state from retained history", () => {
|
||||||
|
const failures = Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
checkedAt: `2026-08-16T0${index}:00:00.000Z`,
|
||||||
|
outcome: "unreachable" as const,
|
||||||
|
durationMs: 100,
|
||||||
|
redirectCount: 0,
|
||||||
|
}))
|
||||||
|
const recovery = {
|
||||||
|
checkedAt: "2026-08-24T11:00:00.000Z",
|
||||||
|
outcome: "reachable" as const,
|
||||||
|
durationMs: 100,
|
||||||
|
redirectCount: 0,
|
||||||
|
schemaValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = registryMonitorEntryStateSchema.parse({
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck: recovery.checkedAt,
|
||||||
|
status: "unavailable",
|
||||||
|
itemCursor: 0,
|
||||||
|
itemNames: [],
|
||||||
|
recentIndex: [...failures, recovery],
|
||||||
|
recentDryRuns: [],
|
||||||
|
daily: [],
|
||||||
|
latestHygiene: {
|
||||||
|
contentTypeJson: true,
|
||||||
|
noDuplicateNames: true,
|
||||||
|
nameMatches: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(state).toMatchObject({
|
||||||
|
consecutiveSuccessfulIndexes: 1,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityOutageSince: failures[0].checkedAt,
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves the last success as the start of a legacy outage", () => {
|
||||||
|
const lastSuccessfulCheck = "2026-08-23T08:00:00.000Z"
|
||||||
|
const state = registryMonitorEntryStateSchema.parse({
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck,
|
||||||
|
status: "degraded",
|
||||||
|
itemCursor: 0,
|
||||||
|
itemNames: [],
|
||||||
|
recentIndex: Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
checkedAt: `2026-08-24T0${index}:00:00.000Z`,
|
||||||
|
outcome: "unreachable" as const,
|
||||||
|
durationMs: 100,
|
||||||
|
redirectCount: 0,
|
||||||
|
})),
|
||||||
|
recentDryRuns: [],
|
||||||
|
daily: [],
|
||||||
|
latestHygiene: {
|
||||||
|
contentTypeJson: true,
|
||||||
|
noDuplicateNames: true,
|
||||||
|
nameMatches: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(state).toMatchObject({
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 3,
|
||||||
|
availabilityOutageSince: lastSuccessfulCheck,
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
import { registryNamespaceSchema } from "../registry-directory"
|
||||||
|
|
||||||
|
export const REGISTRY_HEALTH_SCHEMA_VERSION = 1 as const
|
||||||
|
export const REGISTRY_HEALTH_SCORE_VERSION = 1 as const
|
||||||
|
|
||||||
|
export const registryHealthStatusSchema = z.enum([
|
||||||
|
"healthy",
|
||||||
|
"observing",
|
||||||
|
"degraded",
|
||||||
|
"unavailable",
|
||||||
|
])
|
||||||
|
|
||||||
|
export const registryHealthStatusReasonCodeSchema = z.enum([
|
||||||
|
"healthy_thresholds",
|
||||||
|
"collecting_baseline",
|
||||||
|
"index_unavailable",
|
||||||
|
"recovery_pending",
|
||||||
|
"consecutive_index_failures",
|
||||||
|
"index_schema_invalid",
|
||||||
|
"item_validation_failures",
|
||||||
|
"dry_run_failures",
|
||||||
|
])
|
||||||
|
|
||||||
|
export const registryHealthBreakdownSchema = z
|
||||||
|
.object({
|
||||||
|
reliability: z.number().min(0).max(45),
|
||||||
|
correctness: z.number().min(0).max(25),
|
||||||
|
installability: z.number().min(0).max(20),
|
||||||
|
hygiene: z.number().min(0).max(10),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryHealthSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||||
|
scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION),
|
||||||
|
status: registryHealthStatusSchema,
|
||||||
|
statusReason: z
|
||||||
|
.object({
|
||||||
|
code: registryHealthStatusReasonCodeSchema,
|
||||||
|
message: z.string().min(1),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
.optional(),
|
||||||
|
score: z.number().min(0).max(100),
|
||||||
|
breakdown: registryHealthBreakdownSchema,
|
||||||
|
availability7d: z.number().min(0).max(1),
|
||||||
|
availability30d: z.number().min(0).max(1),
|
||||||
|
monitoringLimited: z.boolean(),
|
||||||
|
firstObservedAt: z.string().datetime().optional(),
|
||||||
|
checkedAt: z.string().datetime(),
|
||||||
|
lastSuccessfulCheck: z.string().datetime().optional(),
|
||||||
|
hidden: z.boolean(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryHealthGlobalMeansSchema = z
|
||||||
|
.object({
|
||||||
|
availability7d: z.number().min(0).max(1),
|
||||||
|
availability30d: z.number().min(0).max(1),
|
||||||
|
indexSchema: z.number().min(0).max(1),
|
||||||
|
itemValidity: z.number().min(0).max(1),
|
||||||
|
dryRun: z.number().min(0).max(1),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryHealthSnapshotSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||||
|
scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION),
|
||||||
|
generatedAt: z.string().datetime(),
|
||||||
|
globalMeans: registryHealthGlobalMeansSchema,
|
||||||
|
registries: z.record(registryNamespaceSchema, registryHealthSchema),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryIndexObservationSchema = z
|
||||||
|
.object({
|
||||||
|
checkedAt: z.string().datetime(),
|
||||||
|
outcome: z.enum(["reachable", "unreachable", "bot_challenge"]),
|
||||||
|
status: z.number().int().min(100).max(599).optional(),
|
||||||
|
failureCode: z.string().optional(),
|
||||||
|
durationMs: z.number().int().nonnegative(),
|
||||||
|
responseSize: z.number().int().nonnegative().optional(),
|
||||||
|
redirectCount: z.number().int().nonnegative(),
|
||||||
|
schemaValid: z.boolean().optional(),
|
||||||
|
contentTypeJson: z.boolean().optional(),
|
||||||
|
duplicateNames: z.boolean().optional(),
|
||||||
|
nameMatches: z.boolean().optional(),
|
||||||
|
itemCount: z.number().int().nonnegative().optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryItemObservationSchema = z
|
||||||
|
.object({
|
||||||
|
checkedAt: z.string().datetime(),
|
||||||
|
item: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
failureCode: z.string().optional(),
|
||||||
|
durationMs: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryDryRunObservationSchema = z
|
||||||
|
.object({
|
||||||
|
checkedAt: z.string().datetime(),
|
||||||
|
item: z.string(),
|
||||||
|
success: z.boolean(),
|
||||||
|
failureCode: z.string().optional(),
|
||||||
|
durationMs: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryHealthDailyBucketSchema = z
|
||||||
|
.object({
|
||||||
|
date: z.string().date(),
|
||||||
|
availabilitySuccesses: z.number().int().nonnegative(),
|
||||||
|
availabilityObservations: z.number().int().nonnegative(),
|
||||||
|
challengeObservations: z.number().int().nonnegative(),
|
||||||
|
schemaSuccesses: z.number().int().nonnegative(),
|
||||||
|
schemaObservations: z.number().int().nonnegative(),
|
||||||
|
itemSuccesses: z.number().int().nonnegative(),
|
||||||
|
itemObservations: z.number().int().nonnegative(),
|
||||||
|
dryRunSuccesses: z.number().int().nonnegative(),
|
||||||
|
dryRunObservations: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
const registryMonitorEntryStateInputSchema = z
|
||||||
|
.object({
|
||||||
|
firstObservedAt: z.string().datetime(),
|
||||||
|
lastSuccessfulCheck: z.string().datetime().optional(),
|
||||||
|
status: registryHealthStatusSchema,
|
||||||
|
consecutiveSuccessfulIndexes: z.number().int().nonnegative().optional(),
|
||||||
|
consecutiveIndexFailures: z.number().int().nonnegative().optional(),
|
||||||
|
availabilityOutageSince: z.string().datetime().optional(),
|
||||||
|
availabilityRecoveryRequired: z.boolean().optional(),
|
||||||
|
itemCursor: z.number().int().nonnegative(),
|
||||||
|
itemNames: z.array(z.string()),
|
||||||
|
recentIndex: z.array(registryIndexObservationSchema),
|
||||||
|
recentDryRuns: z.array(registryDryRunObservationSchema),
|
||||||
|
daily: z.array(registryHealthDailyBucketSchema),
|
||||||
|
latestHygiene: z
|
||||||
|
.object({
|
||||||
|
contentTypeJson: z.boolean().nullable(),
|
||||||
|
noDuplicateNames: z.boolean().nullable(),
|
||||||
|
nameMatches: z.boolean().nullable(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
function getLegacyAvailabilityState(
|
||||||
|
entry: z.infer<typeof registryMonitorEntryStateInputSchema>
|
||||||
|
) {
|
||||||
|
const observations = entry.recentIndex.filter(
|
||||||
|
(observation) => observation.outcome !== "bot_challenge"
|
||||||
|
)
|
||||||
|
let cursor = observations.length - 1
|
||||||
|
let consecutiveSuccessfulIndexes = 0
|
||||||
|
|
||||||
|
while (cursor >= 0) {
|
||||||
|
const observation = observations[cursor]
|
||||||
|
if (observation.outcome !== "reachable" || !observation.schemaValid) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
consecutiveSuccessfulIndexes += 1
|
||||||
|
cursor -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const failureEnd = cursor
|
||||||
|
let precedingFailures = 0
|
||||||
|
while (cursor >= 0 && observations[cursor].outcome === "unreachable") {
|
||||||
|
precedingFailures += 1
|
||||||
|
cursor -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const consecutiveIndexFailures =
|
||||||
|
consecutiveSuccessfulIndexes === 0 ? precedingFailures : 0
|
||||||
|
const recoveryRequired =
|
||||||
|
entry.status === "unavailable" ||
|
||||||
|
(entry.status === "degraded" && precedingFailures >= 3)
|
||||||
|
const failureStart =
|
||||||
|
precedingFailures > 0
|
||||||
|
? observations[failureEnd - precedingFailures + 1]
|
||||||
|
: undefined
|
||||||
|
const outageStart =
|
||||||
|
consecutiveSuccessfulIndexes > 0
|
||||||
|
? failureStart?.checkedAt
|
||||||
|
: (entry.lastSuccessfulCheck ?? failureStart?.checkedAt)
|
||||||
|
const availabilityOutageSince =
|
||||||
|
recoveryRequired || consecutiveIndexFailures > 0
|
||||||
|
? (outageStart ?? entry.firstObservedAt)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
consecutiveSuccessfulIndexes,
|
||||||
|
consecutiveIndexFailures,
|
||||||
|
availabilityOutageSince,
|
||||||
|
availabilityRecoveryRequired: recoveryRequired,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const registryMonitorEntryStateSchema =
|
||||||
|
registryMonitorEntryStateInputSchema.transform((entry) => {
|
||||||
|
if (
|
||||||
|
entry.consecutiveSuccessfulIndexes !== undefined &&
|
||||||
|
entry.consecutiveIndexFailures !== undefined &&
|
||||||
|
entry.availabilityRecoveryRequired !== undefined
|
||||||
|
) {
|
||||||
|
return entry as typeof entry & {
|
||||||
|
consecutiveSuccessfulIndexes: number
|
||||||
|
consecutiveIndexFailures: number
|
||||||
|
availabilityRecoveryRequired: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
...getLegacyAvailabilityState(entry),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const registryMonitorStateSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||||
|
scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION),
|
||||||
|
updatedAt: z.string().datetime(),
|
||||||
|
lastDailyRunAt: z.string().datetime().optional(),
|
||||||
|
lastWeeklyRunAt: z.string().datetime().optional(),
|
||||||
|
registries: z.record(
|
||||||
|
registryNamespaceSchema,
|
||||||
|
registryMonitorEntryStateSchema
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryMonitorRunSchema = z
|
||||||
|
.object({
|
||||||
|
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||||
|
startedAt: z.string().datetime(),
|
||||||
|
completedAt: z.string().datetime(),
|
||||||
|
mode: z.enum(["auto", "hourly", "daily", "weekly", "all"]),
|
||||||
|
totals: z
|
||||||
|
.object({
|
||||||
|
registries: z.number().int().nonnegative(),
|
||||||
|
reachable: z.number().int().nonnegative(),
|
||||||
|
unavailable: z.number().int().nonnegative(),
|
||||||
|
challenges: z.number().int().nonnegative(),
|
||||||
|
itemChecks: z.number().int().nonnegative(),
|
||||||
|
dryRuns: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
|
results: z.record(
|
||||||
|
registryNamespaceSchema,
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
index: registryIndexObservationSchema.optional(),
|
||||||
|
items: z.array(registryItemObservationSchema),
|
||||||
|
dryRun: registryDryRunObservationSchema.optional(),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
),
|
||||||
|
diagnostics: z.array(z.string()),
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export const registryMonitorOutputSchema = z
|
||||||
|
.object({
|
||||||
|
state: registryMonitorStateSchema,
|
||||||
|
snapshot: registryHealthSnapshotSchema,
|
||||||
|
run: registryMonitorRunSchema,
|
||||||
|
})
|
||||||
|
.strict()
|
||||||
|
|
||||||
|
export type RegistryHealth = z.infer<typeof registryHealthSchema>
|
||||||
|
export type RegistryHealthStatusReasonCode = z.infer<
|
||||||
|
typeof registryHealthStatusReasonCodeSchema
|
||||||
|
>
|
||||||
|
export type RegistryHealthBreakdown = z.infer<
|
||||||
|
typeof registryHealthBreakdownSchema
|
||||||
|
>
|
||||||
|
export type RegistryHealthGlobalMeans = z.infer<
|
||||||
|
typeof registryHealthGlobalMeansSchema
|
||||||
|
>
|
||||||
|
export type RegistryHealthSnapshot = z.infer<
|
||||||
|
typeof registryHealthSnapshotSchema
|
||||||
|
>
|
||||||
|
export type RegistryIndexObservation = z.infer<
|
||||||
|
typeof registryIndexObservationSchema
|
||||||
|
>
|
||||||
|
export type RegistryItemObservation = z.infer<
|
||||||
|
typeof registryItemObservationSchema
|
||||||
|
>
|
||||||
|
export type RegistryDryRunObservation = z.infer<
|
||||||
|
typeof registryDryRunObservationSchema
|
||||||
|
>
|
||||||
|
export type RegistryHealthDailyBucket = z.infer<
|
||||||
|
typeof registryHealthDailyBucketSchema
|
||||||
|
>
|
||||||
|
export type RegistryMonitorEntryState = z.infer<
|
||||||
|
typeof registryMonitorEntryStateSchema
|
||||||
|
>
|
||||||
|
export type RegistryMonitorState = z.infer<typeof registryMonitorStateSchema>
|
||||||
|
export type RegistryMonitorRun = z.infer<typeof registryMonitorRunSchema>
|
||||||
|
export type RegistryMonitorOutput = z.infer<typeof registryMonitorOutputSchema>
|
||||||
@@ -0,0 +1,393 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import type {
|
||||||
|
RegistryHealthDailyBucket,
|
||||||
|
RegistryIndexObservation,
|
||||||
|
RegistryMonitorEntryState,
|
||||||
|
} from "./schema"
|
||||||
|
import {
|
||||||
|
calculateRegistryHealth,
|
||||||
|
DEFAULT_GLOBAL_MEANS,
|
||||||
|
getRegistryHealthSignals,
|
||||||
|
} from "./score"
|
||||||
|
|
||||||
|
const NOW = new Date("2026-08-24T12:00:00.000Z")
|
||||||
|
|
||||||
|
function createDailyBucket(overrides: Partial<RegistryHealthDailyBucket> = {}) {
|
||||||
|
return {
|
||||||
|
date: "2026-08-24",
|
||||||
|
availabilitySuccesses: 0,
|
||||||
|
availabilityObservations: 0,
|
||||||
|
challengeObservations: 0,
|
||||||
|
schemaSuccesses: 0,
|
||||||
|
schemaObservations: 0,
|
||||||
|
itemSuccesses: 0,
|
||||||
|
itemObservations: 0,
|
||||||
|
dryRunSuccesses: 0,
|
||||||
|
dryRunObservations: 0,
|
||||||
|
...overrides,
|
||||||
|
} satisfies RegistryHealthDailyBucket
|
||||||
|
}
|
||||||
|
|
||||||
|
function createIndexObservations(
|
||||||
|
count = 25,
|
||||||
|
outcome: RegistryIndexObservation["outcome"] = "reachable"
|
||||||
|
) {
|
||||||
|
return Array.from({ length: count }, (_, index) => ({
|
||||||
|
checkedAt: new Date(
|
||||||
|
NOW.getTime() - (count - index) * 60 * 60 * 1000
|
||||||
|
).toISOString(),
|
||||||
|
outcome,
|
||||||
|
durationMs: 100,
|
||||||
|
redirectCount: 0,
|
||||||
|
schemaValid: outcome === "reachable",
|
||||||
|
})) satisfies RegistryIndexObservation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function createState(overrides: Partial<RegistryMonitorEntryState> = {}) {
|
||||||
|
return {
|
||||||
|
firstObservedAt: "2026-08-22T10:00:00.000Z",
|
||||||
|
lastSuccessfulCheck: "2026-08-24T11:00:00.000Z",
|
||||||
|
status: "healthy",
|
||||||
|
consecutiveSuccessfulIndexes: 25,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityRecoveryRequired: false,
|
||||||
|
itemCursor: 0,
|
||||||
|
itemNames: [],
|
||||||
|
recentIndex: createIndexObservations(),
|
||||||
|
recentDryRuns: [],
|
||||||
|
daily: [
|
||||||
|
createDailyBucket({
|
||||||
|
availabilitySuccesses: 25,
|
||||||
|
availabilityObservations: 25,
|
||||||
|
schemaSuccesses: 25,
|
||||||
|
schemaObservations: 25,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
latestHygiene: {
|
||||||
|
contentTypeJson: null,
|
||||||
|
noDuplicateNames: null,
|
||||||
|
nameMatches: null,
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
} satisfies RegistryMonitorEntryState
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("calculateRegistryHealth", () => {
|
||||||
|
it("derives the published total from rounded components", () => {
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state: createState(),
|
||||||
|
registryUrl: "https://example.com/r/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(health.score).toBe(
|
||||||
|
health.breakdown.reliability +
|
||||||
|
health.breakdown.correctness +
|
||||||
|
health.breakdown.installability +
|
||||||
|
health.breakdown.hygiene
|
||||||
|
)
|
||||||
|
expect(health.score).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(health.score).toBeLessThanOrEqual(100)
|
||||||
|
expect(health.statusReason).toEqual({
|
||||||
|
code: "healthy_thresholds",
|
||||||
|
message: "Recent checks are within healthy thresholds",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("explains when baseline observations are still being collected", () => {
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state: createState({ recentIndex: createIndexObservations(3) }),
|
||||||
|
registryUrl: "https://example.com/r/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(health.status).toBe("observing")
|
||||||
|
expect(health.statusReason).toEqual({
|
||||||
|
code: "collecting_baseline",
|
||||||
|
message: "Collecting baseline data (3 of 24 checks)",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("gives unknown hygiene signals half credit", () => {
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state: createState(),
|
||||||
|
registryUrl: "https://example.com/r/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(health.breakdown.hygiene).toBe(6.25)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("uses a cadence-scaled prior for weekly dry runs", () => {
|
||||||
|
const state = createState({
|
||||||
|
daily: [
|
||||||
|
createDailyBucket({
|
||||||
|
availabilitySuccesses: 25,
|
||||||
|
availabilityObservations: 25,
|
||||||
|
schemaSuccesses: 25,
|
||||||
|
schemaObservations: 25,
|
||||||
|
dryRunSuccesses: 1,
|
||||||
|
dryRunObservations: 1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state,
|
||||||
|
registryUrl: "https://example.com/r/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(health.breakdown.installability).toBe(18.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("excludes challenge observations from availability", () => {
|
||||||
|
const state = createState({
|
||||||
|
recentIndex: [
|
||||||
|
...createIndexObservations(),
|
||||||
|
{
|
||||||
|
checkedAt: NOW.toISOString(),
|
||||||
|
outcome: "bot_challenge",
|
||||||
|
durationMs: 100,
|
||||||
|
redirectCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const signals = getRegistryHealthSignals(
|
||||||
|
state,
|
||||||
|
"https://example.com/{name}.json",
|
||||||
|
NOW
|
||||||
|
)
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state,
|
||||||
|
registryUrl: "https://example.com/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(signals.availability7d).toEqual({
|
||||||
|
successes: 25,
|
||||||
|
observations: 25,
|
||||||
|
})
|
||||||
|
expect(health.monitoringLimited).toBe(true)
|
||||||
|
expect(health.status).toBe("healthy")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("hides a registry only after seven days unavailable", () => {
|
||||||
|
const state = createState({
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck: "2026-08-16T00:00:00.000Z",
|
||||||
|
status: "unavailable",
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 8,
|
||||||
|
availabilityOutageSince: "2026-08-16T00:00:00.000Z",
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
recentIndex: createIndexObservations(8, "unreachable"),
|
||||||
|
})
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state,
|
||||||
|
registryUrl: "https://example.com/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(health.status).toBe("unavailable")
|
||||||
|
expect(health.statusReason).toEqual({
|
||||||
|
code: "index_unavailable",
|
||||||
|
message: "No successful index check in the last 24 hours",
|
||||||
|
})
|
||||||
|
expect(health.hidden).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps an unavailable registry visible before seven days", () => {
|
||||||
|
const state = createState({
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck: "2026-08-20T00:00:00.000Z",
|
||||||
|
status: "unavailable",
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 8,
|
||||||
|
availabilityOutageSince: "2026-08-20T00:00:00.000Z",
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
recentIndex: createIndexObservations(8, "unreachable"),
|
||||||
|
})
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state,
|
||||||
|
registryUrl: "https://example.com/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(health.status).toBe("unavailable")
|
||||||
|
expect(health.hidden).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps a long outage hidden through the first recovery success", () => {
|
||||||
|
const recovery = createIndexObservations(1)[0]
|
||||||
|
const state = createState({
|
||||||
|
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||||
|
lastSuccessfulCheck: recovery.checkedAt,
|
||||||
|
status: "unavailable",
|
||||||
|
consecutiveSuccessfulIndexes: 1,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityOutageSince: "2026-08-16T00:00:00.000Z",
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
recentIndex: [...createIndexObservations(24, "unreachable"), recovery],
|
||||||
|
})
|
||||||
|
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state,
|
||||||
|
registryUrl: "https://example.com/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(health.status).toBe("unavailable")
|
||||||
|
expect(health.statusReason?.code).toBe("recovery_pending")
|
||||||
|
expect(health.hidden).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("requires two successful checks to recover from availability degradation", () => {
|
||||||
|
const history = createIndexObservations()
|
||||||
|
const failures = createIndexObservations(3, "unreachable")
|
||||||
|
const successes = createIndexObservations(2)
|
||||||
|
const state = createState({
|
||||||
|
status: "degraded",
|
||||||
|
consecutiveSuccessfulIndexes: 1,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityOutageSince: failures[0].checkedAt,
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
recentIndex: [...history, ...failures, successes[0]],
|
||||||
|
})
|
||||||
|
|
||||||
|
const recovering = calculateRegistryHealth({
|
||||||
|
state,
|
||||||
|
registryUrl: "https://example.com/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
const recovered = calculateRegistryHealth({
|
||||||
|
state: {
|
||||||
|
...state,
|
||||||
|
consecutiveSuccessfulIndexes: 2,
|
||||||
|
availabilityOutageSince: undefined,
|
||||||
|
availabilityRecoveryRequired: false,
|
||||||
|
recentIndex: [...state.recentIndex, successes[1]],
|
||||||
|
},
|
||||||
|
registryUrl: "https://example.com/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(recovering.status).toBe("degraded")
|
||||||
|
expect(recovering.statusReason).toEqual({
|
||||||
|
code: "recovery_pending",
|
||||||
|
message: "Waiting for a second successful recovery check",
|
||||||
|
})
|
||||||
|
expect(recovered.status).toBe("healthy")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("explains each degraded status condition", () => {
|
||||||
|
const failures = calculateRegistryHealth({
|
||||||
|
state: createState({
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 3,
|
||||||
|
availabilityOutageSince: "2026-08-24T09:00:00.000Z",
|
||||||
|
availabilityRecoveryRequired: true,
|
||||||
|
recentIndex: [
|
||||||
|
...createIndexObservations(),
|
||||||
|
...createIndexObservations(3, "unreachable"),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
registryUrl: "https://example.com/r/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
const observations = createIndexObservations()
|
||||||
|
const invalidSchema = calculateRegistryHealth({
|
||||||
|
state: createState({
|
||||||
|
recentIndex: [
|
||||||
|
...observations.slice(0, -1),
|
||||||
|
{ ...observations.at(-1)!, schemaValid: false },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
registryUrl: "https://example.com/r/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
const invalidItems = calculateRegistryHealth({
|
||||||
|
state: createState({
|
||||||
|
daily: [
|
||||||
|
createDailyBucket({
|
||||||
|
availabilitySuccesses: 25,
|
||||||
|
availabilityObservations: 25,
|
||||||
|
schemaSuccesses: 25,
|
||||||
|
schemaObservations: 25,
|
||||||
|
itemSuccesses: 8,
|
||||||
|
itemObservations: 10,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
registryUrl: "https://example.com/r/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
const dryRuns = calculateRegistryHealth({
|
||||||
|
state: createState({
|
||||||
|
recentDryRuns: [
|
||||||
|
{
|
||||||
|
checkedAt: "2026-08-17T12:00:00.000Z",
|
||||||
|
item: "button",
|
||||||
|
success: false,
|
||||||
|
durationMs: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
checkedAt: "2026-08-24T12:00:00.000Z",
|
||||||
|
item: "button",
|
||||||
|
success: false,
|
||||||
|
durationMs: 100,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
registryUrl: "https://example.com/r/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(failures.statusReason).toEqual({
|
||||||
|
code: "consecutive_index_failures",
|
||||||
|
message: "The registry index failed 3 consecutive checks",
|
||||||
|
})
|
||||||
|
expect(invalidSchema.statusReason).toEqual({
|
||||||
|
code: "index_schema_invalid",
|
||||||
|
message: "The registry index failed schema validation",
|
||||||
|
})
|
||||||
|
expect(invalidItems.statusReason).toEqual({
|
||||||
|
code: "item_validation_failures",
|
||||||
|
message: "Sampled registry items are failing validation",
|
||||||
|
})
|
||||||
|
expect(dryRuns.statusReason).toEqual({
|
||||||
|
code: "dry_run_failures",
|
||||||
|
message: "The last two CLI dry-run checks failed",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("recovers correctness degradation when its condition clears", () => {
|
||||||
|
const state = createState({
|
||||||
|
status: "degraded",
|
||||||
|
recentIndex: createIndexObservations(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const health = calculateRegistryHealth({
|
||||||
|
state,
|
||||||
|
registryUrl: "https://example.com/{name}.json",
|
||||||
|
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||||
|
now: NOW,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(health.status).toBe("healthy")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import {
|
||||||
|
REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||||
|
REGISTRY_HEALTH_SCORE_VERSION,
|
||||||
|
type RegistryHealth,
|
||||||
|
type RegistryHealthGlobalMeans,
|
||||||
|
type RegistryMonitorEntryState,
|
||||||
|
type RegistryMonitorState,
|
||||||
|
} from "./schema"
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
const DEFAULT_GLOBAL_MEANS: RegistryHealthGlobalMeans = {
|
||||||
|
availability7d: 0.85,
|
||||||
|
availability30d: 0.85,
|
||||||
|
indexSchema: 0.9,
|
||||||
|
itemValidity: 0.9,
|
||||||
|
dryRun: 0.9,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRIOR_WEIGHTS = {
|
||||||
|
availability7d: 24,
|
||||||
|
availability30d: 24,
|
||||||
|
indexSchema: 12,
|
||||||
|
itemValidity: 10,
|
||||||
|
dryRun: 3,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type RateSignal = {
|
||||||
|
successes: number
|
||||||
|
observations: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegistryHealthSignals = {
|
||||||
|
availability7d: RateSignal
|
||||||
|
availability30d: RateSignal
|
||||||
|
indexSchema: RateSignal
|
||||||
|
itemValidity: RateSignal
|
||||||
|
dryRun: RateSignal
|
||||||
|
hygiene: {
|
||||||
|
https: boolean
|
||||||
|
contentTypeJson: boolean | null
|
||||||
|
noDuplicateNames: boolean | null
|
||||||
|
nameMatches: boolean | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function round(value: number, digits: number) {
|
||||||
|
const factor = 10 ** digits
|
||||||
|
return Math.round((value + Number.EPSILON) * factor) / factor
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinWindow(timestamp: string, now: Date, days: number) {
|
||||||
|
const age = now.getTime() - new Date(timestamp).getTime()
|
||||||
|
return age >= 0 && age < days * DAY_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBucketWithinWindow(date: string, now: Date, days: number) {
|
||||||
|
return isWithinWindow(`${date}T00:00:00.000Z`, now, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumDailySignal(
|
||||||
|
state: RegistryMonitorEntryState,
|
||||||
|
now: Date,
|
||||||
|
days: number,
|
||||||
|
successesKey:
|
||||||
|
| "availabilitySuccesses"
|
||||||
|
| "schemaSuccesses"
|
||||||
|
| "itemSuccesses"
|
||||||
|
| "dryRunSuccesses",
|
||||||
|
observationsKey:
|
||||||
|
| "availabilityObservations"
|
||||||
|
| "schemaObservations"
|
||||||
|
| "itemObservations"
|
||||||
|
| "dryRunObservations"
|
||||||
|
) {
|
||||||
|
return state.daily.reduce<RateSignal>(
|
||||||
|
(signal, bucket) => {
|
||||||
|
if (!isBucketWithinWindow(bucket.date, now, days)) {
|
||||||
|
return signal
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.successes += bucket[successesKey]
|
||||||
|
signal.observations += bucket[observationsKey]
|
||||||
|
return signal
|
||||||
|
},
|
||||||
|
{ successes: 0, observations: 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegistryHealthSignals(
|
||||||
|
state: RegistryMonitorEntryState,
|
||||||
|
registryUrl: string,
|
||||||
|
now: Date
|
||||||
|
): RegistryHealthSignals {
|
||||||
|
const availability7d = state.recentIndex.reduce<RateSignal>(
|
||||||
|
(signal, observation) => {
|
||||||
|
if (
|
||||||
|
observation.outcome === "bot_challenge" ||
|
||||||
|
!isWithinWindow(observation.checkedAt, now, 7)
|
||||||
|
) {
|
||||||
|
return signal
|
||||||
|
}
|
||||||
|
|
||||||
|
signal.observations += 1
|
||||||
|
signal.successes += observation.outcome === "reachable" ? 1 : 0
|
||||||
|
return signal
|
||||||
|
},
|
||||||
|
{ successes: 0, observations: 0 }
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
availability7d,
|
||||||
|
availability30d: sumDailySignal(
|
||||||
|
state,
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
"availabilitySuccesses",
|
||||||
|
"availabilityObservations"
|
||||||
|
),
|
||||||
|
indexSchema: sumDailySignal(
|
||||||
|
state,
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
"schemaSuccesses",
|
||||||
|
"schemaObservations"
|
||||||
|
),
|
||||||
|
itemValidity: sumDailySignal(
|
||||||
|
state,
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
"itemSuccesses",
|
||||||
|
"itemObservations"
|
||||||
|
),
|
||||||
|
dryRun: sumDailySignal(
|
||||||
|
state,
|
||||||
|
now,
|
||||||
|
30,
|
||||||
|
"dryRunSuccesses",
|
||||||
|
"dryRunObservations"
|
||||||
|
),
|
||||||
|
hygiene: {
|
||||||
|
https: registryUrl.startsWith("https://"),
|
||||||
|
contentTypeJson: state.latestHygiene.contentTypeJson,
|
||||||
|
noDuplicateNames: state.latestHygiene.noDuplicateNames,
|
||||||
|
nameMatches: state.latestHygiene.nameMatches,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getObservedMean(
|
||||||
|
signals: RateSignal[],
|
||||||
|
minimumObservations: number,
|
||||||
|
fallback: number
|
||||||
|
) {
|
||||||
|
const eligible = signals.filter(
|
||||||
|
(signal) => signal.observations >= minimumObservations
|
||||||
|
)
|
||||||
|
const observations = eligible.reduce(
|
||||||
|
(total, signal) => total + signal.observations,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
|
||||||
|
if (observations === 0) {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const successes = eligible.reduce(
|
||||||
|
(total, signal) => total + signal.successes,
|
||||||
|
0
|
||||||
|
)
|
||||||
|
return successes / observations
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateGlobalMeans(
|
||||||
|
state: RegistryMonitorState,
|
||||||
|
registryUrls: Record<string, string>,
|
||||||
|
now: Date
|
||||||
|
): RegistryHealthGlobalMeans {
|
||||||
|
const signals = Object.entries(state.registries).map(([name, entry]) =>
|
||||||
|
getRegistryHealthSignals(entry, registryUrls[name] ?? "http://invalid", now)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
availability7d: getObservedMean(
|
||||||
|
signals.map((signal) => signal.availability7d),
|
||||||
|
24,
|
||||||
|
DEFAULT_GLOBAL_MEANS.availability7d
|
||||||
|
),
|
||||||
|
availability30d: getObservedMean(
|
||||||
|
signals.map((signal) => signal.availability30d),
|
||||||
|
24,
|
||||||
|
DEFAULT_GLOBAL_MEANS.availability30d
|
||||||
|
),
|
||||||
|
indexSchema: getObservedMean(
|
||||||
|
signals.map((signal) => signal.indexSchema),
|
||||||
|
12,
|
||||||
|
DEFAULT_GLOBAL_MEANS.indexSchema
|
||||||
|
),
|
||||||
|
itemValidity: getObservedMean(
|
||||||
|
signals.map((signal) => signal.itemValidity),
|
||||||
|
10,
|
||||||
|
DEFAULT_GLOBAL_MEANS.itemValidity
|
||||||
|
),
|
||||||
|
dryRun: getObservedMean(
|
||||||
|
signals.map((signal) => signal.dryRun),
|
||||||
|
3,
|
||||||
|
DEFAULT_GLOBAL_MEANS.dryRun
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function smooth(signal: RateSignal, globalMean: number, priorWeight: number) {
|
||||||
|
return (
|
||||||
|
(signal.successes + globalMean * priorWeight) /
|
||||||
|
(signal.observations + priorWeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHygienePoints(value: boolean | null) {
|
||||||
|
if (value === null) {
|
||||||
|
return 1.25
|
||||||
|
}
|
||||||
|
|
||||||
|
return value ? 2.5 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNonChallengeObservations(state: RegistryMonitorEntryState) {
|
||||||
|
return state.recentIndex.filter(
|
||||||
|
(observation) => observation.outcome !== "bot_challenge"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnavailableSince(state: RegistryMonitorEntryState) {
|
||||||
|
return state.availabilityOutageSince ?? state.firstObservedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveStatus(
|
||||||
|
state: RegistryMonitorEntryState,
|
||||||
|
signals: RegistryHealthSignals,
|
||||||
|
now: Date
|
||||||
|
) {
|
||||||
|
const observations = getNonChallengeObservations(state)
|
||||||
|
const latest = observations.at(-1)
|
||||||
|
const unavailableFor =
|
||||||
|
now.getTime() - new Date(getUnavailableSince(state)).getTime()
|
||||||
|
const unavailable =
|
||||||
|
unavailableFor >= DAY_MS &&
|
||||||
|
(state.availabilityRecoveryRequired || latest?.outcome === "unreachable")
|
||||||
|
|
||||||
|
if (unavailable) {
|
||||||
|
if (latest?.outcome === "reachable") {
|
||||||
|
return {
|
||||||
|
status: "unavailable" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "recovery_pending" as const,
|
||||||
|
message: "Waiting for a second successful recovery check",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "unavailable" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "index_unavailable" as const,
|
||||||
|
message: "No successful index check in the last 24 hours",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const observationSpan = latest
|
||||||
|
? new Date(latest.checkedAt).getTime() -
|
||||||
|
new Date(state.firstObservedAt).getTime()
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (observations.length < 24 || observationSpan < DAY_MS) {
|
||||||
|
return {
|
||||||
|
status: "observing" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "collecting_baseline" as const,
|
||||||
|
message:
|
||||||
|
observations.length < 24
|
||||||
|
? `Collecting baseline data (${observations.length} of 24 checks)`
|
||||||
|
: "Collecting a full day of baseline data",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trailingFailures = state.consecutiveIndexFailures
|
||||||
|
const latestSchemaInvalid =
|
||||||
|
latest?.outcome === "reachable" && latest.schemaValid === false
|
||||||
|
const itemDegraded =
|
||||||
|
signals.itemValidity.observations >= 10 &&
|
||||||
|
signals.itemValidity.successes / signals.itemValidity.observations < 0.9
|
||||||
|
const recentDryRuns = state.recentDryRuns.slice(-2)
|
||||||
|
const dryRunDegraded =
|
||||||
|
recentDryRuns.length === 2 &&
|
||||||
|
recentDryRuns.every((observation) => !observation.success)
|
||||||
|
|
||||||
|
if (trailingFailures >= 3) {
|
||||||
|
return {
|
||||||
|
status: "degraded" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "consecutive_index_failures" as const,
|
||||||
|
message: `The registry index failed ${trailingFailures} consecutive checks`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestSchemaInvalid) {
|
||||||
|
return {
|
||||||
|
status: "degraded" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "index_schema_invalid" as const,
|
||||||
|
message: "The registry index failed schema validation",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemDegraded) {
|
||||||
|
return {
|
||||||
|
status: "degraded" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "item_validation_failures" as const,
|
||||||
|
message: "Sampled registry items are failing validation",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dryRunDegraded) {
|
||||||
|
return {
|
||||||
|
status: "degraded" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "dry_run_failures" as const,
|
||||||
|
message: "The last two CLI dry-run checks failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.availabilityRecoveryRequired) {
|
||||||
|
return {
|
||||||
|
status: "degraded" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "recovery_pending" as const,
|
||||||
|
message: "Waiting for a second successful recovery check",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "healthy" as const,
|
||||||
|
statusReason: {
|
||||||
|
code: "healthy_thresholds" as const,
|
||||||
|
message: "Recent checks are within healthy thresholds",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calculateRegistryHealth({
|
||||||
|
state,
|
||||||
|
registryUrl,
|
||||||
|
globalMeans,
|
||||||
|
now,
|
||||||
|
}: {
|
||||||
|
state: RegistryMonitorEntryState
|
||||||
|
registryUrl: string
|
||||||
|
globalMeans: RegistryHealthGlobalMeans
|
||||||
|
now: Date
|
||||||
|
}): RegistryHealth {
|
||||||
|
const signals = getRegistryHealthSignals(state, registryUrl, now)
|
||||||
|
const availability7d = smooth(
|
||||||
|
signals.availability7d,
|
||||||
|
globalMeans.availability7d,
|
||||||
|
PRIOR_WEIGHTS.availability7d
|
||||||
|
)
|
||||||
|
const availability30d = smooth(
|
||||||
|
signals.availability30d,
|
||||||
|
globalMeans.availability30d,
|
||||||
|
PRIOR_WEIGHTS.availability30d
|
||||||
|
)
|
||||||
|
const indexSchema = smooth(
|
||||||
|
signals.indexSchema,
|
||||||
|
globalMeans.indexSchema,
|
||||||
|
PRIOR_WEIGHTS.indexSchema
|
||||||
|
)
|
||||||
|
const itemValidity = smooth(
|
||||||
|
signals.itemValidity,
|
||||||
|
globalMeans.itemValidity,
|
||||||
|
PRIOR_WEIGHTS.itemValidity
|
||||||
|
)
|
||||||
|
const dryRun = smooth(
|
||||||
|
signals.dryRun,
|
||||||
|
globalMeans.dryRun,
|
||||||
|
PRIOR_WEIGHTS.dryRun
|
||||||
|
)
|
||||||
|
|
||||||
|
const breakdown = {
|
||||||
|
reliability: round(
|
||||||
|
45 * (0.65 * availability7d + 0.35 * availability30d),
|
||||||
|
3
|
||||||
|
),
|
||||||
|
correctness: round(10 * indexSchema + 15 * itemValidity, 3),
|
||||||
|
installability: round(20 * dryRun, 3),
|
||||||
|
hygiene: round(
|
||||||
|
(signals.hygiene.https ? 2.5 : 0) +
|
||||||
|
getHygienePoints(signals.hygiene.contentTypeJson) +
|
||||||
|
getHygienePoints(signals.hygiene.noDuplicateNames) +
|
||||||
|
getHygienePoints(signals.hygiene.nameMatches),
|
||||||
|
3
|
||||||
|
),
|
||||||
|
}
|
||||||
|
const score = round(
|
||||||
|
breakdown.reliability +
|
||||||
|
breakdown.correctness +
|
||||||
|
breakdown.installability +
|
||||||
|
breakdown.hygiene,
|
||||||
|
3
|
||||||
|
)
|
||||||
|
const { status, statusReason } = deriveStatus(state, signals, now)
|
||||||
|
const unavailableFor =
|
||||||
|
now.getTime() - new Date(getUnavailableSince(state)).getTime()
|
||||||
|
const hidden = status === "unavailable" && unavailableFor >= 7 * DAY_MS
|
||||||
|
const latestObservation = state.recentIndex.at(-1)
|
||||||
|
const checkedAt = latestObservation?.checkedAt ?? state.firstObservedAt
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||||
|
scoreVersion: REGISTRY_HEALTH_SCORE_VERSION,
|
||||||
|
status,
|
||||||
|
statusReason,
|
||||||
|
score,
|
||||||
|
breakdown,
|
||||||
|
availability7d: round(availability7d, 6),
|
||||||
|
availability30d: round(availability30d, 6),
|
||||||
|
monitoringLimited: latestObservation?.outcome === "bot_challenge",
|
||||||
|
firstObservedAt: state.firstObservedAt,
|
||||||
|
checkedAt,
|
||||||
|
lastSuccessfulCheck: state.lastSuccessfulCheck,
|
||||||
|
hidden,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DEFAULT_GLOBAL_MEANS, PRIOR_WEIGHTS }
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { RegistryMonitorEntryState } from "./schema"
|
||||||
|
|
||||||
|
function createRegistryMonitorEntryState(now: Date) {
|
||||||
|
return {
|
||||||
|
firstObservedAt: now.toISOString(),
|
||||||
|
status: "observing",
|
||||||
|
consecutiveSuccessfulIndexes: 0,
|
||||||
|
consecutiveIndexFailures: 0,
|
||||||
|
availabilityRecoveryRequired: false,
|
||||||
|
itemCursor: 0,
|
||||||
|
itemNames: [],
|
||||||
|
recentIndex: [],
|
||||||
|
recentDryRuns: [],
|
||||||
|
daily: [],
|
||||||
|
latestHygiene: {
|
||||||
|
contentTypeJson: null,
|
||||||
|
noDuplicateNames: null,
|
||||||
|
nameMatches: null,
|
||||||
|
},
|
||||||
|
} satisfies RegistryMonitorEntryState
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createRegistryMonitorEntryState }
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
"registry:build": "pnpm --filter=@shadcn/react build && pnpm --filter=@shadcn/helpers build && pnpm --filter=shadcn build && bun run ./scripts/build-registry.mts",
|
"registry:build": "pnpm --filter=@shadcn/react build && pnpm --filter=@shadcn/helpers build && pnpm --filter=shadcn build && bun run ./scripts/build-registry.mts",
|
||||||
"registry:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.mts",
|
"registry:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.mts",
|
||||||
"explore:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-explore.mts",
|
"explore:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-explore.mts",
|
||||||
|
"registry:health": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/monitor-registries.mts",
|
||||||
|
"registry:health:prepare": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/monitor-registries.mts --phase prepare",
|
||||||
|
"registry:health:check": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/monitor-registries.mts --phase check",
|
||||||
|
"registry:health:publish": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/monitor-registries.mts --phase publish",
|
||||||
"validate:registries": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/validate-registries.mts",
|
"validate:registries": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/validate-registries.mts",
|
||||||
"test:apps": "bun run ./scripts/build-test-app.mts",
|
"test:apps": "bun run ./scripts/build-test-app.mts",
|
||||||
"postinstall": "fumadocs-mdx"
|
"postinstall": "fumadocs-mdx"
|
||||||
@@ -110,6 +114,7 @@
|
|||||||
"@types/react": "19.2.2",
|
"@types/react": "19.2.2",
|
||||||
"@types/react-dom": "19.2.2",
|
"@types/react-dom": "19.2.2",
|
||||||
"@typescript-eslint/parser": "^8.31.0",
|
"@typescript-eslint/parser": "^8.31.0",
|
||||||
|
"@vercel/blob": "^2.8.0",
|
||||||
"agentation": "^2.2.1",
|
"agentation": "^2.2.1",
|
||||||
"baseline-browser-mapping": "^2.10.0",
|
"baseline-browser-mapping": "^2.10.0",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
@@ -120,8 +125,10 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"typescript-eslint": "^8.46.2",
|
"typescript-eslint": "^8.46.2",
|
||||||
|
"undici": "^7.27.2",
|
||||||
"unist-builder": "3.0.0",
|
"unist-builder": "3.0.0",
|
||||||
"unist-util-visit": "^4.1.2"
|
"unist-util-visit": "^4.1.2",
|
||||||
|
"vitest": "^3.2.6"
|
||||||
},
|
},
|
||||||
"prettier": {
|
"prettier": {
|
||||||
"endOfLine": "lf",
|
"endOfLine": "lf",
|
||||||
|
|||||||
@@ -98,3 +98,94 @@ Notes:
|
|||||||
- `--style new-york-v4` is rejected because it is a legacy source registry, not a
|
- `--style new-york-v4` is rejected because it is a legacy source registry, not a
|
||||||
generated combination. Use `--registry new-york-v4` instead.
|
generated combination. Use `--registry new-york-v4` instead.
|
||||||
- Unknown targets fail with the list of valid style ids.
|
- Unknown targets fail with the list of valid style ids.
|
||||||
|
|
||||||
|
## Public registry health monitoring
|
||||||
|
|
||||||
|
`directory.json` remains the authored source of truth for public registry
|
||||||
|
metadata. The scheduled monitor reads that file, checks each registry, and
|
||||||
|
publishes generated state to a private Vercel Blob store. Generated health data
|
||||||
|
must never be committed to the repository.
|
||||||
|
|
||||||
|
The monitor writes these paths:
|
||||||
|
|
||||||
|
```text
|
||||||
|
registry-health/v1/state.json
|
||||||
|
registry-health/v1/latest.json
|
||||||
|
registry-health/v1/runs/<timestamp>.json
|
||||||
|
registry-health/v1/daily/<date>.json
|
||||||
|
```
|
||||||
|
|
||||||
|
`latest.json` is the only Blob document read by the website. The server reads it
|
||||||
|
with Blob credentials, then the public `/r/registries.json` route merges the
|
||||||
|
sanitized health overlay by exact namespace. The route fails open to the
|
||||||
|
original directory payload when health is disabled, stale, or unavailable.
|
||||||
|
If a newly added registry is not in the latest snapshot yet, it remains in the
|
||||||
|
payload without `health` until the monitor first observes it.
|
||||||
|
The route uses five-minute ISR, so normal traffic is served from cache and does
|
||||||
|
not read Blob on every request.
|
||||||
|
|
||||||
|
Each newly generated health entry includes a stable status reason code and a
|
||||||
|
human-readable message for its primary status condition. The public route does
|
||||||
|
not expose raw monitor diagnostics, and status reasons do not affect scores or
|
||||||
|
ranking.
|
||||||
|
|
||||||
|
### Authentication and configuration
|
||||||
|
|
||||||
|
New Blob connections use Vercel OIDC inside the linked Vercel project. A
|
||||||
|
server-only `BLOB_READ_WRITE_TOKEN` is also supported when OIDC is unavailable.
|
||||||
|
The website must receive one of these credentials to read `latest.json`; client
|
||||||
|
code never receives them.
|
||||||
|
|
||||||
|
The monitor runs in GitHub Actions, outside Vercel's OIDC runtime. Configure a
|
||||||
|
separate repository Actions secret named `BLOB_READ_WRITE_TOKEN` for that
|
||||||
|
workflow. The token is available only while downloading the previous private
|
||||||
|
state and publishing the new snapshot. The registry check step, contributor URL
|
||||||
|
requests, and CLI dry-run subprocesses run without Blob credentials. Never
|
||||||
|
expose the token through a `NEXT_PUBLIC_` variable, logs, or step summaries.
|
||||||
|
|
||||||
|
Configure this Vercel server environment variable:
|
||||||
|
|
||||||
|
- `REGISTRY_HEALTH_ENABLED`: set to `1` to merge the additive health object into
|
||||||
|
`/r/registries.json`; set to `0` to return the original four-field payload.
|
||||||
|
The Registry Directory does not consume the health object during the initial
|
||||||
|
data-collection phase.
|
||||||
|
|
||||||
|
### Running and rollout
|
||||||
|
|
||||||
|
The `Monitor Registries` workflow runs hourly and supports manual `hourly`,
|
||||||
|
`daily`, `weekly`, and `all` modes. Registry failures are recorded as data. The
|
||||||
|
workflow fails only when its own configuration, state, or publication fails.
|
||||||
|
Automatic daily and weekly work uses the last successful phase timestamps in
|
||||||
|
`state.json`, so a delayed cron run does not skip those checks.
|
||||||
|
|
||||||
|
To run the monitor locally with credentials loaded from the linked Vercel
|
||||||
|
project, use the workspace-root Turbo command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MONITOR_MODE=hourly vercel env run -- pnpm registry:health
|
||||||
|
```
|
||||||
|
|
||||||
|
If the local environment file lives under `apps/v4`, run from that directory
|
||||||
|
and use `pnpm -w registry:health` so pnpm still selects the workspace-root
|
||||||
|
script.
|
||||||
|
|
||||||
|
Turbo builds the monitor's workspace dependencies before running it. Local runs
|
||||||
|
run the same three phases as the workflow: authenticated state download,
|
||||||
|
credential-free registry checks, and authenticated publication. They publish
|
||||||
|
health state to the connected Blob store. Valid modes are `auto`, `hourly`,
|
||||||
|
`daily`, `weekly`, and `all`.
|
||||||
|
|
||||||
|
For the initial rollout:
|
||||||
|
|
||||||
|
1. Connect a private Blob store to the Vercel project.
|
||||||
|
2. Configure the GitHub Actions write secret.
|
||||||
|
3. Set `REGISTRY_HEALTH_ENABLED=1` and deploy the additive API overlay.
|
||||||
|
4. Dispatch an hourly run and confirm all four Blob path families.
|
||||||
|
5. Collect at least seven days of observations and inspect false positives.
|
||||||
|
6. Add the Registry Directory presentation in a follow-up after the data and
|
||||||
|
thresholds have been reviewed.
|
||||||
|
|
||||||
|
To roll back the public API overlay immediately, set
|
||||||
|
`REGISTRY_HEALTH_ENABLED=0`. Disable the scheduled workflow separately if its
|
||||||
|
requests are causing load or false positives. Blob history can remain in place
|
||||||
|
for diagnosis.
|
||||||
|
|||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import { promises as fs } from "node:fs"
|
||||||
|
import path from "node:path"
|
||||||
|
|
||||||
|
import { registryDirectorySchema } from "../lib/registry-directory"
|
||||||
|
import {
|
||||||
|
cleanRegistryHealthHistory,
|
||||||
|
loadRegistryMonitorState,
|
||||||
|
publishRegistryHealth,
|
||||||
|
} from "../lib/registry-health/blob"
|
||||||
|
import { runLocalCliDryRun } from "../lib/registry-health/dry-run"
|
||||||
|
import {
|
||||||
|
runRegistryMonitor,
|
||||||
|
type RegistryMonitorMode,
|
||||||
|
} from "../lib/registry-health/monitor"
|
||||||
|
import {
|
||||||
|
registryMonitorOutputSchema,
|
||||||
|
registryMonitorStateSchema,
|
||||||
|
} from "../lib/registry-health/schema"
|
||||||
|
import directory from "../registry/directory.json"
|
||||||
|
|
||||||
|
const PHASES = new Set(["prepare", "check", "publish"] as const)
|
||||||
|
const MODES = new Set<RegistryMonitorMode>([
|
||||||
|
"auto",
|
||||||
|
"hourly",
|
||||||
|
"daily",
|
||||||
|
"weekly",
|
||||||
|
"all",
|
||||||
|
])
|
||||||
|
const WORK_DIRECTORY = path.resolve(process.cwd(), ".registry-health")
|
||||||
|
const PREVIOUS_STATE_PATH = path.join(WORK_DIRECTORY, "previous-state.json")
|
||||||
|
const MONITOR_OUTPUT_PATH = path.join(WORK_DIRECTORY, "monitor-output.json")
|
||||||
|
const BLOB_ENVIRONMENT_VARIABLES = [
|
||||||
|
"BLOB_READ_WRITE_TOKEN",
|
||||||
|
"VERCEL_OIDC_TOKEN",
|
||||||
|
"BLOB_STORE_ID",
|
||||||
|
] as const
|
||||||
|
|
||||||
|
type MonitorPhase = "prepare" | "check" | "publish"
|
||||||
|
|
||||||
|
function getArgument(name: string) {
|
||||||
|
const index = process.argv.indexOf(name)
|
||||||
|
if (index >= 0) {
|
||||||
|
const value = process.argv[index + 1]
|
||||||
|
if (!value || value.startsWith("--")) {
|
||||||
|
throw new Error(`Missing value for ${name}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const argument = process.argv.find((value) => value.startsWith(`${name}=`))
|
||||||
|
if (!argument) return undefined
|
||||||
|
|
||||||
|
const value = argument.slice(name.length + 1)
|
||||||
|
if (!value) {
|
||||||
|
throw new Error(`Missing value for ${name}`)
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPhase() {
|
||||||
|
const value = getArgument("--phase")
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
if (!PHASES.has(value as MonitorPhase)) {
|
||||||
|
throw new Error(`Invalid monitor phase: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as MonitorPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMode() {
|
||||||
|
const value = getArgument("--mode") ?? process.env.MONITOR_MODE
|
||||||
|
const mode = (value ?? "auto") as RegistryMonitorMode
|
||||||
|
|
||||||
|
if (!MODES.has(mode)) {
|
||||||
|
throw new Error(`Invalid monitor mode: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mode
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBlobToken() {
|
||||||
|
const token = process.env.BLOB_READ_WRITE_TOKEN
|
||||||
|
const hasVercelOidc =
|
||||||
|
process.env.VERCEL_OIDC_TOKEN && process.env.BLOB_STORE_ID
|
||||||
|
|
||||||
|
if (!token && !hasVercelOidc) {
|
||||||
|
throw new Error(
|
||||||
|
"Blob authentication is missing. Configure BLOB_READ_WRITE_TOKEN outside Vercel or Vercel Blob OIDC inside a linked project."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertCredentialFreeCheck() {
|
||||||
|
const exposed = BLOB_ENVIRONMENT_VARIABLES.filter((name) => process.env[name])
|
||||||
|
|
||||||
|
if (exposed.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`The check phase must not receive Blob credentials: ${exposed.join(", ")}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(pathname: string) {
|
||||||
|
return JSON.parse(await fs.readFile(pathname, "utf8")) as unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeJson(pathname: string, value: unknown) {
|
||||||
|
await fs.mkdir(path.dirname(pathname), { recursive: true })
|
||||||
|
const temporaryPath = `${pathname}.${process.pid}.tmp`
|
||||||
|
await fs.writeFile(temporaryPath, JSON.stringify(value))
|
||||||
|
await fs.rename(temporaryPath, pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeStepSummary({
|
||||||
|
latestPath,
|
||||||
|
mode,
|
||||||
|
totals,
|
||||||
|
diagnostics,
|
||||||
|
deleted,
|
||||||
|
}: {
|
||||||
|
latestPath: string
|
||||||
|
mode: RegistryMonitorMode
|
||||||
|
totals: {
|
||||||
|
registries: number
|
||||||
|
reachable: number
|
||||||
|
unavailable: number
|
||||||
|
challenges: number
|
||||||
|
itemChecks: number
|
||||||
|
dryRuns: number
|
||||||
|
}
|
||||||
|
diagnostics: string[]
|
||||||
|
deleted: number
|
||||||
|
}) {
|
||||||
|
if (!process.env.GITHUB_STEP_SUMMARY) return
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
"## Registry health monitor",
|
||||||
|
"",
|
||||||
|
`- Mode: \`${mode}\``,
|
||||||
|
`- Registries: ${totals.registries}`,
|
||||||
|
`- Reachable indexes: ${totals.reachable}`,
|
||||||
|
`- Unavailable indexes: ${totals.unavailable}`,
|
||||||
|
`- Bot challenges: ${totals.challenges}`,
|
||||||
|
`- Item checks: ${totals.itemChecks}`,
|
||||||
|
`- Dry runs: ${totals.dryRuns}`,
|
||||||
|
`- Expired history deleted: ${deleted}`,
|
||||||
|
`- Latest snapshot: ${latestPath}`,
|
||||||
|
]
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
rows.push("", "### Diagnostics", "")
|
||||||
|
rows.push(...diagnostics.map((diagnostic) => `- ${diagnostic}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `${rows.join("\n")}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareMonitor() {
|
||||||
|
const token = getBlobToken()
|
||||||
|
const previousState = await loadRegistryMonitorState({ token })
|
||||||
|
|
||||||
|
await fs.rm(MONITOR_OUTPUT_PATH, { force: true })
|
||||||
|
await writeJson(PREVIOUS_STATE_PATH, previousState)
|
||||||
|
console.log(
|
||||||
|
previousState
|
||||||
|
? "Registry health state downloaded."
|
||||||
|
: "No previous registry health state found."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRegistries() {
|
||||||
|
assertCredentialFreeCheck()
|
||||||
|
|
||||||
|
const previousStateValue = await readJson(PREVIOUS_STATE_PATH)
|
||||||
|
const previousState =
|
||||||
|
previousStateValue === null
|
||||||
|
? null
|
||||||
|
: registryMonitorStateSchema.parse(previousStateValue)
|
||||||
|
const parsedDirectory = registryDirectorySchema.parse(directory)
|
||||||
|
const cliPath = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
"../../packages/shadcn/dist/index.js"
|
||||||
|
)
|
||||||
|
const result = await runRegistryMonitor({
|
||||||
|
directory: parsedDirectory,
|
||||||
|
previousState,
|
||||||
|
mode: getMode(),
|
||||||
|
runDryRun: (options) => runLocalCliDryRun({ ...options, cliPath }),
|
||||||
|
})
|
||||||
|
|
||||||
|
await writeJson(MONITOR_OUTPUT_PATH, result)
|
||||||
|
console.log(
|
||||||
|
`Registry checks completed for ${result.run.totals.registries} registries.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishMonitor() {
|
||||||
|
const token = getBlobToken()
|
||||||
|
const result = registryMonitorOutputSchema.parse(
|
||||||
|
await readJson(MONITOR_OUTPUT_PATH)
|
||||||
|
)
|
||||||
|
const publication = await publishRegistryHealth({
|
||||||
|
...result,
|
||||||
|
token,
|
||||||
|
})
|
||||||
|
const cleanup = await cleanRegistryHealthHistory({ token })
|
||||||
|
|
||||||
|
await writeStepSummary({
|
||||||
|
latestPath: publication.latestPath,
|
||||||
|
mode: result.run.mode,
|
||||||
|
totals: result.run.totals,
|
||||||
|
diagnostics: result.run.diagnostics,
|
||||||
|
deleted: cleanup.deleted,
|
||||||
|
})
|
||||||
|
await fs.rm(WORK_DIRECTORY, { recursive: true, force: true })
|
||||||
|
console.log(`Registry health snapshot published: ${publication.latestPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPackageScript(
|
||||||
|
script: string,
|
||||||
|
environment: NodeJS.ProcessEnv
|
||||||
|
) {
|
||||||
|
const isWindows = process.platform === "win32"
|
||||||
|
const command = isWindows ? "pnpm.cmd" : "pnpm"
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const child = spawn(command, ["run", script], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
env: environment,
|
||||||
|
shell: isWindows,
|
||||||
|
stdio: "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
|
child.on("error", reject)
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolve()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runAllPhases() {
|
||||||
|
await runPackageScript("registry:health:prepare", process.env)
|
||||||
|
|
||||||
|
const checkEnvironment = { ...process.env }
|
||||||
|
for (const name of BLOB_ENVIRONMENT_VARIABLES) {
|
||||||
|
delete checkEnvironment[name]
|
||||||
|
}
|
||||||
|
delete checkEnvironment.GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
await runPackageScript("registry:health:check", checkEnvironment)
|
||||||
|
await runPackageScript("registry:health:publish", process.env)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const phase = getPhase()
|
||||||
|
|
||||||
|
if (!phase) {
|
||||||
|
await runAllPhases()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "prepare") {
|
||||||
|
await prepareMonitor()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "check") {
|
||||||
|
await checkRegistries()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await publishMonitor()
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(
|
||||||
|
"Registry health monitor failed:",
|
||||||
|
error instanceof Error ? error.message : "Unknown error"
|
||||||
|
)
|
||||||
|
process.exitCode = 1
|
||||||
|
})
|
||||||
@@ -1,32 +1,11 @@
|
|||||||
import { promises as fs } from "fs"
|
import { promises as fs } from "fs"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { z } from "zod"
|
|
||||||
|
|
||||||
const registryEntrySchema = z.object({
|
import {
|
||||||
name: z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9-_]*$/),
|
createPublicRegistryDirectory,
|
||||||
homepage: z.string().url(),
|
publicRegistryDirectorySchema,
|
||||||
url: z.string().refine((url) => url.includes("{name}"), {
|
registryDirectorySchema,
|
||||||
message: "URL must include {name} placeholder",
|
} from "../lib/registry-directory"
|
||||||
}),
|
|
||||||
description: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const registriesSchema = z.array(registryEntrySchema)
|
|
||||||
|
|
||||||
const directoryEntrySchema = registryEntrySchema.extend({
|
|
||||||
logo: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
const directorySchema = z.array(directoryEntrySchema)
|
|
||||||
|
|
||||||
function getRegistries(directory: z.infer<typeof directorySchema>) {
|
|
||||||
return directory.map(({ name, homepage, url, description }) => ({
|
|
||||||
name,
|
|
||||||
homepage,
|
|
||||||
url,
|
|
||||||
description,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
let hasErrors = false
|
let hasErrors = false
|
||||||
@@ -36,7 +15,7 @@ async function main() {
|
|||||||
const directoryContent = await fs.readFile(directoryFile, "utf-8")
|
const directoryContent = await fs.readFile(directoryFile, "utf-8")
|
||||||
const directoryData = JSON.parse(directoryContent)
|
const directoryData = JSON.parse(directoryContent)
|
||||||
|
|
||||||
const directoryResult = directorySchema.safeParse(directoryData)
|
const directoryResult = registryDirectorySchema.safeParse(directoryData)
|
||||||
if (!directoryResult.success) {
|
if (!directoryResult.success) {
|
||||||
console.error("❌ directory.json validation failed:")
|
console.error("❌ directory.json validation failed:")
|
||||||
console.error(directoryResult.error.format())
|
console.error(directoryResult.error.format())
|
||||||
@@ -47,8 +26,8 @@ async function main() {
|
|||||||
|
|
||||||
// 2. Validate the public registries payload served by /r/registries.json.
|
// 2. Validate the public registries payload served by /r/registries.json.
|
||||||
if (directoryResult.success) {
|
if (directoryResult.success) {
|
||||||
const registriesResult = registriesSchema.safeParse(
|
const registriesResult = publicRegistryDirectorySchema.safeParse(
|
||||||
getRegistries(directoryResult.data)
|
createPublicRegistryDirectory(directoryResult.data)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!registriesResult.success) {
|
if (!registriesResult.success) {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
"build:packages": "turbo run build --filter=./packages/*",
|
"build:packages": "turbo run build --filter=./packages/*",
|
||||||
"registry:build": "pnpm --filter=v4 registry:build && pnpm lint:fix && pnpm format:write -- --loglevel silent",
|
"registry:build": "pnpm --filter=v4 registry:build && pnpm lint:fix && pnpm format:write -- --loglevel silent",
|
||||||
"registry:capture": "pnpm --filter=v4 registry:capture",
|
"registry:capture": "pnpm --filter=v4 registry:capture",
|
||||||
|
"registry:health": "turbo run registry:health --filter=v4",
|
||||||
|
"registry:health:prepare": "turbo run registry:health:prepare --filter=v4",
|
||||||
|
"registry:health:check": "turbo run registry:health:check --filter=v4",
|
||||||
|
"registry:health:publish": "turbo run registry:health:publish --filter=v4",
|
||||||
"explore:capture": "pnpm --filter=v4 explore:capture",
|
"explore:capture": "pnpm --filter=v4 explore:capture",
|
||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"shadcn:dev": "turbo run dev --filter=shadcn",
|
"shadcn:dev": "turbo run dev --filter=shadcn",
|
||||||
|
|||||||
Generated
+107
-1
@@ -379,6 +379,9 @@ importers:
|
|||||||
'@typescript-eslint/parser':
|
'@typescript-eslint/parser':
|
||||||
specifier: ^8.31.0
|
specifier: ^8.31.0
|
||||||
version: 8.39.0(eslint@9.26.0(hono@4.12.23)(jiti@2.7.0))(typescript@5.9.2)
|
version: 8.39.0(eslint@9.26.0(hono@4.12.23)(jiti@2.7.0))(typescript@5.9.2)
|
||||||
|
'@vercel/blob':
|
||||||
|
specifier: ^2.8.0
|
||||||
|
version: 2.8.0
|
||||||
agentation:
|
agentation:
|
||||||
specifier: ^2.2.1
|
specifier: ^2.2.1
|
||||||
version: 2.2.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
version: 2.2.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
@@ -409,12 +412,18 @@ importers:
|
|||||||
typescript-eslint:
|
typescript-eslint:
|
||||||
specifier: ^8.46.2
|
specifier: ^8.46.2
|
||||||
version: 8.46.2(eslint@9.26.0(hono@4.12.23)(jiti@2.7.0))(typescript@5.9.2)
|
version: 8.46.2(eslint@9.26.0(hono@4.12.23)(jiti@2.7.0))(typescript@5.9.2)
|
||||||
|
undici:
|
||||||
|
specifier: ^7.27.2
|
||||||
|
version: 7.27.2
|
||||||
unist-builder:
|
unist-builder:
|
||||||
specifier: 3.0.0
|
specifier: 3.0.0
|
||||||
version: 3.0.0
|
version: 3.0.0
|
||||||
unist-util-visit:
|
unist-util-visit:
|
||||||
specifier: ^4.1.2
|
specifier: ^4.1.2
|
||||||
version: 4.1.2
|
version: 4.1.2
|
||||||
|
vitest:
|
||||||
|
specifier: ^3.2.6
|
||||||
|
version: 3.2.6(@types/debug@4.1.12)(@types/node@20.19.10)(@vitest/browser@3.2.6)(jiti@2.7.0)(jsdom@28.1.0(@noble/hashes@1.8.0))(lightningcss@1.32.0)(msw@2.10.4(@types/node@20.19.10)(typescript@5.9.2))(tsx@4.20.3)(yaml@2.8.1)
|
||||||
|
|
||||||
packages/helpers:
|
packages/helpers:
|
||||||
devDependencies:
|
devDependencies:
|
||||||
@@ -4617,10 +4626,25 @@ packages:
|
|||||||
vue-router:
|
vue-router:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@vercel/blob@2.8.0':
|
||||||
|
resolution: {integrity: sha512-Nu+HWKpkgovCh/ezlG7wCVwF7RErTzLzZMbGKFBdGBCbTKyK+s5VXPLl+0+TpNEQPH8AVaGzOpIsXUOtkqylCQ==}
|
||||||
|
engines: {node: '>=20.0.0'}
|
||||||
|
|
||||||
|
'@vercel/cli-config@0.2.4':
|
||||||
|
resolution: {integrity: sha512-kZ5SojbrV06GHoU6QIWGwDXLov+s9rWZ7QqdqKfJfBGCNUieGfgaCjeeenNy8Y+QC0bwC0dZ2B4l5Hvdmrgpdw==}
|
||||||
|
|
||||||
|
'@vercel/cli-exec@1.0.1':
|
||||||
|
resolution: {integrity: sha512-g9XerViJ/paZujufXYcu5XYI2vU2rtB4sgdpjUHde5RnOkdmpu0ngH46LCFGHoPXO/C+qDPSczIHIRN+8Q2YKQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
'@vercel/oidc@3.2.0':
|
'@vercel/oidc@3.2.0':
|
||||||
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
|
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
|
'@vercel/oidc@3.8.5':
|
||||||
|
resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
|
||||||
'@vitest/browser@3.2.6':
|
'@vitest/browser@3.2.6':
|
||||||
resolution: {integrity: sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==}
|
resolution: {integrity: sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -4846,6 +4870,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
async-retry@1.3.3:
|
||||||
|
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
|
||||||
|
|
||||||
asynckit@0.4.0:
|
asynckit@0.4.0:
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
@@ -6728,6 +6755,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
is-buffer@2.0.5:
|
||||||
|
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
|
||||||
|
engines: {node: '>=4'}
|
||||||
|
|
||||||
is-bun-module@2.0.0:
|
is-bun-module@2.0.0:
|
||||||
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
|
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
|
||||||
|
|
||||||
@@ -6943,6 +6974,9 @@ packages:
|
|||||||
joi@17.13.3:
|
joi@17.13.3:
|
||||||
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
|
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
|
||||||
|
|
||||||
|
jose@5.10.0:
|
||||||
|
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
|
||||||
|
|
||||||
jose@6.1.3:
|
jose@6.1.3:
|
||||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||||
|
|
||||||
@@ -7815,6 +7849,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
os-paths@4.4.0:
|
||||||
|
resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==}
|
||||||
|
engines: {node: '>= 6.0'}
|
||||||
|
|
||||||
outdent@0.5.0:
|
outdent@0.5.0:
|
||||||
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
|
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
|
||||||
|
|
||||||
@@ -8565,6 +8603,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
retry@0.13.1:
|
||||||
|
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
|
||||||
|
engines: {node: '>= 4'}
|
||||||
|
|
||||||
reusify@1.1.0:
|
reusify@1.1.0:
|
||||||
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
|
||||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||||
@@ -9257,6 +9299,10 @@ packages:
|
|||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
undici@6.28.0:
|
||||||
|
resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==}
|
||||||
|
engines: {node: '>=18.17'}
|
||||||
|
|
||||||
undici@7.27.2:
|
undici@7.27.2:
|
||||||
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
|
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
|
||||||
engines: {node: '>=20.18.1'}
|
engines: {node: '>=20.18.1'}
|
||||||
@@ -9671,6 +9717,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
|
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
|
|
||||||
|
xdg-app-paths@5.5.1:
|
||||||
|
resolution: {integrity: sha512-hI3flOB4PLZIy5prbtTpirobtPE2ZtZ52szO+2mM9Efp6ErM398La+C1lIpNWDfNoQk+6Lsi6nMcCwVB7pxeMQ==}
|
||||||
|
engines: {node: '>= 6.0'}
|
||||||
|
|
||||||
|
xdg-portable@7.3.0:
|
||||||
|
resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==}
|
||||||
|
engines: {node: '>= 6.0'}
|
||||||
|
|
||||||
xml-name-validator@5.0.0:
|
xml-name-validator@5.0.0:
|
||||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -9742,6 +9796,9 @@ packages:
|
|||||||
zod@3.25.76:
|
zod@3.25.76:
|
||||||
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
|
||||||
|
|
||||||
|
zod@4.1.11:
|
||||||
|
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
|
||||||
|
|
||||||
zod@4.3.6:
|
zod@4.3.6:
|
||||||
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
|
||||||
|
|
||||||
@@ -9831,7 +9888,7 @@ snapshots:
|
|||||||
'@antfu/install-pkg@1.1.0':
|
'@antfu/install-pkg@1.1.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
package-manager-detector: 1.3.0
|
package-manager-detector: 1.3.0
|
||||||
tinyexec: 1.0.2
|
tinyexec: 1.2.4
|
||||||
|
|
||||||
'@antfu/ni@25.0.0':
|
'@antfu/ni@25.0.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13597,8 +13654,32 @@ snapshots:
|
|||||||
next: 16.3.0-canary.97(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
next: 16.3.0-canary.97(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(@types/node@20.19.10)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||||
react: 19.2.3
|
react: 19.2.3
|
||||||
|
|
||||||
|
'@vercel/blob@2.8.0':
|
||||||
|
dependencies:
|
||||||
|
'@vercel/oidc': 3.8.5
|
||||||
|
async-retry: 1.3.3
|
||||||
|
is-buffer: 2.0.5
|
||||||
|
is-node-process: 1.2.0
|
||||||
|
throttleit: 2.1.0
|
||||||
|
undici: 6.28.0
|
||||||
|
|
||||||
|
'@vercel/cli-config@0.2.4':
|
||||||
|
dependencies:
|
||||||
|
xdg-app-paths: 5.5.1
|
||||||
|
zod: 4.1.11
|
||||||
|
|
||||||
|
'@vercel/cli-exec@1.0.1':
|
||||||
|
dependencies:
|
||||||
|
execa: 5.1.1
|
||||||
|
|
||||||
'@vercel/oidc@3.2.0': {}
|
'@vercel/oidc@3.2.0': {}
|
||||||
|
|
||||||
|
'@vercel/oidc@3.8.5':
|
||||||
|
dependencies:
|
||||||
|
'@vercel/cli-config': 0.2.4
|
||||||
|
'@vercel/cli-exec': 1.0.1
|
||||||
|
jose: 5.10.0
|
||||||
|
|
||||||
'@vitest/browser@3.2.6(msw@2.10.4(@types/node@20.19.10)(typescript@5.9.2))(playwright@1.61.0)(vite@7.3.2(@types/node@20.19.10)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@3.2.6)':
|
'@vitest/browser@3.2.6(msw@2.10.4(@types/node@20.19.10)(typescript@5.9.2))(playwright@1.61.0)(vite@7.3.2(@types/node@20.19.10)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.20.3)(yaml@2.8.1))(vitest@3.2.6)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@testing-library/dom': 10.4.1
|
'@testing-library/dom': 10.4.1
|
||||||
@@ -13886,6 +13967,10 @@ snapshots:
|
|||||||
|
|
||||||
async-function@1.0.0: {}
|
async-function@1.0.0: {}
|
||||||
|
|
||||||
|
async-retry@1.3.3:
|
||||||
|
dependencies:
|
||||||
|
retry: 0.13.1
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
autoprefixer@10.4.21(postcss@8.5.15):
|
autoprefixer@10.4.21(postcss@8.5.15):
|
||||||
@@ -16253,6 +16338,8 @@ snapshots:
|
|||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
has-tostringtag: 1.0.2
|
has-tostringtag: 1.0.2
|
||||||
|
|
||||||
|
is-buffer@2.0.5: {}
|
||||||
|
|
||||||
is-bun-module@2.0.0:
|
is-bun-module@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
@@ -16436,6 +16523,8 @@ snapshots:
|
|||||||
'@sideway/formula': 3.0.1
|
'@sideway/formula': 3.0.1
|
||||||
'@sideway/pinpoint': 2.0.0
|
'@sideway/pinpoint': 2.0.0
|
||||||
|
|
||||||
|
jose@5.10.0: {}
|
||||||
|
|
||||||
jose@6.1.3: {}
|
jose@6.1.3: {}
|
||||||
|
|
||||||
jotai@2.15.0(@babel/core@7.28.0)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.3):
|
jotai@2.15.0(@babel/core@7.28.0)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.3):
|
||||||
@@ -17496,6 +17585,8 @@ snapshots:
|
|||||||
string-width: 7.2.0
|
string-width: 7.2.0
|
||||||
strip-ansi: 7.1.0
|
strip-ansi: 7.1.0
|
||||||
|
|
||||||
|
os-paths@4.4.0: {}
|
||||||
|
|
||||||
outdent@0.5.0: {}
|
outdent@0.5.0: {}
|
||||||
|
|
||||||
outvariant@1.4.3: {}
|
outvariant@1.4.3: {}
|
||||||
@@ -18381,6 +18472,8 @@ snapshots:
|
|||||||
onetime: 7.0.0
|
onetime: 7.0.0
|
||||||
signal-exit: 4.1.0
|
signal-exit: 4.1.0
|
||||||
|
|
||||||
|
retry@0.13.1: {}
|
||||||
|
|
||||||
reusify@1.1.0: {}
|
reusify@1.1.0: {}
|
||||||
|
|
||||||
rimraf@6.0.1:
|
rimraf@6.0.1:
|
||||||
@@ -19295,6 +19388,8 @@ snapshots:
|
|||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
undici@6.28.0: {}
|
||||||
|
|
||||||
undici@7.27.2: {}
|
undici@7.27.2: {}
|
||||||
|
|
||||||
unicorn-magic@0.1.0: {}
|
unicorn-magic@0.1.0: {}
|
||||||
@@ -19848,6 +19943,15 @@ snapshots:
|
|||||||
is-wsl: 3.1.0
|
is-wsl: 3.1.0
|
||||||
powershell-utils: 0.1.0
|
powershell-utils: 0.1.0
|
||||||
|
|
||||||
|
xdg-app-paths@5.5.1:
|
||||||
|
dependencies:
|
||||||
|
os-paths: 4.4.0
|
||||||
|
xdg-portable: 7.3.0
|
||||||
|
|
||||||
|
xdg-portable@7.3.0:
|
||||||
|
dependencies:
|
||||||
|
os-paths: 4.4.0
|
||||||
|
|
||||||
xml-name-validator@5.0.0: {}
|
xml-name-validator@5.0.0: {}
|
||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
@@ -19902,6 +20006,8 @@ snapshots:
|
|||||||
|
|
||||||
zod@3.25.76: {}
|
zod@3.25.76: {}
|
||||||
|
|
||||||
|
zod@4.1.11: {}
|
||||||
|
|
||||||
zod@4.3.6: {}
|
zod@4.3.6: {}
|
||||||
|
|
||||||
zod@4.4.3: {}
|
zod@4.4.3: {}
|
||||||
|
|||||||
+39
@@ -61,6 +61,45 @@
|
|||||||
},
|
},
|
||||||
"registry:build": {
|
"registry:build": {
|
||||||
"outputs": []
|
"outputs": []
|
||||||
|
},
|
||||||
|
"registry:health": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"cache": false,
|
||||||
|
"env": ["MONITOR_MODE"],
|
||||||
|
"passThroughEnv": [
|
||||||
|
"BLOB_READ_WRITE_TOKEN",
|
||||||
|
"VERCEL_OIDC_TOKEN",
|
||||||
|
"BLOB_STORE_ID",
|
||||||
|
"GITHUB_STEP_SUMMARY"
|
||||||
|
],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"registry:health:prepare": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"cache": false,
|
||||||
|
"passThroughEnv": [
|
||||||
|
"BLOB_READ_WRITE_TOKEN",
|
||||||
|
"VERCEL_OIDC_TOKEN",
|
||||||
|
"BLOB_STORE_ID"
|
||||||
|
],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"registry:health:check": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"cache": false,
|
||||||
|
"env": ["MONITOR_MODE"],
|
||||||
|
"outputs": []
|
||||||
|
},
|
||||||
|
"registry:health:publish": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"cache": false,
|
||||||
|
"passThroughEnv": [
|
||||||
|
"BLOB_READ_WRITE_TOKEN",
|
||||||
|
"VERCEL_OIDC_TOKEN",
|
||||||
|
"BLOB_STORE_ID",
|
||||||
|
"GITHUB_STEP_SUMMARY"
|
||||||
|
],
|
||||||
|
"outputs": []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user