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:
shadcn
2026-08-25 15:49:59 +04:00
committed by GitHub
parent b9938d9463
commit 352f2094bd
29 changed files with 5065 additions and 38 deletions
+75
View File
@@ -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
+2
View File
@@ -50,6 +50,8 @@ plans
# vitest browser mode writes these only on test failure.
__screenshots__
.codex-artifacts
.registry-health
.vercel
.tmp*
CONTEXT.md
+187
View File
@@ -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")
})
})
+54 -7
View File
@@ -1,16 +1,63 @@
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"
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() {
const registries = directory.map(({ name, homepage, url, description }) => ({
name,
homepage,
url,
description,
}))
if (process.env.REGISTRY_HEALTH_ENABLED !== "1") {
return NextResponse.json(registries)
}
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
})
)
}
+175
View File
@@ -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.
+1
View File
@@ -5,6 +5,7 @@
"getting-started",
"github",
"registry-index",
"health",
"examples",
"namespace",
"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 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
1. The registry must be open source and publicly accessible.
+1
View File
@@ -6,6 +6,7 @@ export const PAGES_NEW = [
"/docs/components/base/questionnaire",
"/docs/components/aria/questionnaire",
"/docs/react/questionnaire",
"/docs/registry/health",
]
export const PAGES_UPDATED = []
+76
View File
@@ -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 }
+202
View File
@@ -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)
})
})
+290
View File
@@ -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 })
}
})
})
+146
View File
@@ -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 }
+613
View File
@@ -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)
})
})
+566
View File
@@ -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),
}
}
+242
View File
@@ -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)
})
})
+586
View File
@@ -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,
})
})
})
+308
View File
@@ -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>
+393
View File
@@ -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")
})
})
+442
View File
@@ -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 }
+23
View File
@@ -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 }
+8 -1
View File
@@ -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:capture": "tsx --tsconfig ./tsconfig.scripts.json ./scripts/capture-registry.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",
"test:apps": "bun run ./scripts/build-test-app.mts",
"postinstall": "fumadocs-mdx"
@@ -110,6 +114,7 @@
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@typescript-eslint/parser": "^8.31.0",
"@vercel/blob": "^2.8.0",
"agentation": "^2.2.1",
"baseline-browser-mapping": "^2.10.0",
"eslint": "^9",
@@ -120,8 +125,10 @@
"tw-animate-css": "^1.4.0",
"typescript": "^5",
"typescript-eslint": "^8.46.2",
"undici": "^7.27.2",
"unist-builder": "3.0.0",
"unist-util-visit": "^4.1.2"
"unist-util-visit": "^4.1.2",
"vitest": "^3.2.6"
},
"prettier": {
"endOfLine": "lf",
+91
View File
@@ -98,3 +98,94 @@ Notes:
- `--style new-york-v4` is rejected because it is a legacy source registry, not a
generated combination. Use `--registry new-york-v4` instead.
- 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.
+294
View File
@@ -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
})
+8 -29
View File
@@ -1,32 +1,11 @@
import { promises as fs } from "fs"
import path from "path"
import { z } from "zod"
const registryEntrySchema = z.object({
name: z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9-_]*$/),
homepage: z.string().url(),
url: z.string().refine((url) => url.includes("{name}"), {
message: "URL must include {name} placeholder",
}),
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,
}))
}
import {
createPublicRegistryDirectory,
publicRegistryDirectorySchema,
registryDirectorySchema,
} from "../lib/registry-directory"
async function main() {
let hasErrors = false
@@ -36,7 +15,7 @@ async function main() {
const directoryContent = await fs.readFile(directoryFile, "utf-8")
const directoryData = JSON.parse(directoryContent)
const directoryResult = directorySchema.safeParse(directoryData)
const directoryResult = registryDirectorySchema.safeParse(directoryData)
if (!directoryResult.success) {
console.error("❌ directory.json validation failed:")
console.error(directoryResult.error.format())
@@ -47,8 +26,8 @@ async function main() {
// 2. Validate the public registries payload served by /r/registries.json.
if (directoryResult.success) {
const registriesResult = registriesSchema.safeParse(
getRegistries(directoryResult.data)
const registriesResult = publicRegistryDirectorySchema.safeParse(
createPublicRegistryDirectory(directoryResult.data)
)
if (!registriesResult.success) {
+4
View File
@@ -17,6 +17,10 @@
"build:packages": "turbo run build --filter=./packages/*",
"registry:build": "pnpm --filter=v4 registry:build && pnpm lint:fix && pnpm format:write -- --loglevel silent",
"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",
"dev": "turbo run dev",
"shadcn:dev": "turbo run dev --filter=shadcn",
+107 -1
View File
@@ -379,6 +379,9 @@ importers:
'@typescript-eslint/parser':
specifier: ^8.31.0
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:
specifier: ^2.2.1
version: 2.2.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -409,12 +412,18 @@ importers:
typescript-eslint:
specifier: ^8.46.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:
specifier: 3.0.0
version: 3.0.0
unist-util-visit:
specifier: ^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:
devDependencies:
@@ -4617,10 +4626,25 @@ packages:
vue-router:
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':
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
engines: {node: '>= 20'}
'@vercel/oidc@3.8.5':
resolution: {integrity: sha512-RwXYtnt6za+5UO4IaLywN/6B95AlLqynPRUWRJxeJ/qufwkcLUbZNUxYtzT0uMpuraWhlNcGqPNGkTnZr4BGBw==}
engines: {node: '>= 20'}
'@vitest/browser@3.2.6':
resolution: {integrity: sha512-CNjSynGBtAVOMTfQITv6Bc8da4/XTU1izorocbDStjUsynXcgx2FHVssh+10a8bKd/BxoqDdQtuSbYHfk302Wg==}
peerDependencies:
@@ -4846,6 +4870,9 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'}
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -6728,6 +6755,10 @@ packages:
resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==}
engines: {node: '>= 0.4'}
is-buffer@2.0.5:
resolution: {integrity: sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==}
engines: {node: '>=4'}
is-bun-module@2.0.0:
resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==}
@@ -6943,6 +6974,9 @@ packages:
joi@17.13.3:
resolution: {integrity: sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==}
jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
@@ -7815,6 +7849,10 @@ packages:
resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==}
engines: {node: '>=18'}
os-paths@4.4.0:
resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==}
engines: {node: '>= 6.0'}
outdent@0.5.0:
resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==}
@@ -8565,6 +8603,10 @@ packages:
resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==}
engines: {node: '>=18'}
retry@0.13.1:
resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==}
engines: {node: '>= 4'}
reusify@1.1.0:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
@@ -9257,6 +9299,10 @@ packages:
undici-types@6.21.0:
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:
resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==}
engines: {node: '>=20.18.1'}
@@ -9671,6 +9717,14 @@ packages:
resolution: {integrity: sha512-3sFIGLiaDP7rTO4xh3g+b3AzhYDIUGGywE/WsmqzJWDxus5aJXVnPTNC/6L+r2WzrwXqVOdD262OaO+cEyPMSQ==}
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:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -9742,6 +9796,9 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
zod@4.1.11:
resolution: {integrity: sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@@ -9831,7 +9888,7 @@ snapshots:
'@antfu/install-pkg@1.1.0':
dependencies:
package-manager-detector: 1.3.0
tinyexec: 1.0.2
tinyexec: 1.2.4
'@antfu/ni@25.0.0':
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)
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.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)':
dependencies:
'@testing-library/dom': 10.4.1
@@ -13886,6 +13967,10 @@ snapshots:
async-function@1.0.0: {}
async-retry@1.3.3:
dependencies:
retry: 0.13.1
asynckit@0.4.0: {}
autoprefixer@10.4.21(postcss@8.5.15):
@@ -16253,6 +16338,8 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-buffer@2.0.5: {}
is-bun-module@2.0.0:
dependencies:
semver: 7.7.3
@@ -16436,6 +16523,8 @@ snapshots:
'@sideway/formula': 3.0.1
'@sideway/pinpoint': 2.0.0
jose@5.10.0: {}
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):
@@ -17496,6 +17585,8 @@ snapshots:
string-width: 7.2.0
strip-ansi: 7.1.0
os-paths@4.4.0: {}
outdent@0.5.0: {}
outvariant@1.4.3: {}
@@ -18381,6 +18472,8 @@ snapshots:
onetime: 7.0.0
signal-exit: 4.1.0
retry@0.13.1: {}
reusify@1.1.0: {}
rimraf@6.0.1:
@@ -19295,6 +19388,8 @@ snapshots:
undici-types@6.21.0: {}
undici@6.28.0: {}
undici@7.27.2: {}
unicorn-magic@0.1.0: {}
@@ -19848,6 +19943,15 @@ snapshots:
is-wsl: 3.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: {}
xmlchars@2.2.0: {}
@@ -19902,6 +20006,8 @@ snapshots:
zod@3.25.76: {}
zod@4.1.11: {}
zod@4.3.6: {}
zod@4.4.3: {}
+39
View File
@@ -61,6 +61,45 @@
},
"registry:build": {
"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": []
}
}
}