mirror of
https://github.com/shadcn-ui/ui.git
synced 2026-09-14 20:00:29 +08:00
feat(registry): add health monitoring (#11616)
* feat(registry): add health monitoring * fix(registry): cache health directory route * fix(registry): harden health state transitions
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
name: Monitor Registries
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 * * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: Registry checks to run
|
||||
required: true
|
||||
default: auto
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- hourly
|
||||
- daily
|
||||
- weekly
|
||||
- all
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: registry-health-monitor
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
monitor:
|
||||
name: Monitor registry health
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.19.1
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
id: pnpm-install
|
||||
with:
|
||||
version: 10.33.4
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: echo "pnpm_cache_dir=$(pnpm store path)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Download previous health state
|
||||
env:
|
||||
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
|
||||
run: pnpm registry:health:prepare
|
||||
|
||||
- name: Check registries
|
||||
env:
|
||||
MONITOR_MODE: ${{ inputs.mode || 'auto' }}
|
||||
run: pnpm registry:health:check
|
||||
|
||||
- name: Publish health snapshot
|
||||
env:
|
||||
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
|
||||
run: pnpm registry:health:publish
|
||||
@@ -50,6 +50,8 @@ plans
|
||||
# vitest browser mode writes these only on test failure.
|
||||
__screenshots__
|
||||
.codex-artifacts
|
||||
.registry-health
|
||||
.vercel
|
||||
.tmp*
|
||||
|
||||
CONTEXT.md
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { loadRegistryHealthSnapshot } from "@/lib/registry-health/blob"
|
||||
import type {
|
||||
RegistryHealth,
|
||||
RegistryHealthSnapshot,
|
||||
} from "@/lib/registry-health/schema"
|
||||
import directory from "@/registry/directory.json"
|
||||
|
||||
import { dynamic, GET, revalidate } from "./route"
|
||||
|
||||
vi.mock("@/lib/registry-health/blob", () => ({
|
||||
loadRegistryHealthSnapshot: vi.fn(),
|
||||
}))
|
||||
|
||||
const GENERATED_AT = "2026-08-24T12:00:00.000Z"
|
||||
const loadSnapshot = vi.mocked(loadRegistryHealthSnapshot)
|
||||
|
||||
function createHealth(overrides: Partial<RegistryHealth> = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
scoreVersion: 1,
|
||||
status: "healthy",
|
||||
statusReason: {
|
||||
code: "healthy_thresholds",
|
||||
message: "Recent checks are within healthy thresholds",
|
||||
},
|
||||
score: 90,
|
||||
breakdown: {
|
||||
reliability: 40,
|
||||
correctness: 23,
|
||||
installability: 18,
|
||||
hygiene: 9,
|
||||
},
|
||||
availability7d: 0.99,
|
||||
availability30d: 0.98,
|
||||
monitoringLimited: false,
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
checkedAt: GENERATED_AT,
|
||||
lastSuccessfulCheck: GENERATED_AT,
|
||||
hidden: false,
|
||||
...overrides,
|
||||
} satisfies RegistryHealth
|
||||
}
|
||||
|
||||
function createSnapshot(registries: RegistryHealthSnapshot["registries"]) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
scoreVersion: 1,
|
||||
generatedAt: GENERATED_AT,
|
||||
globalMeans: {
|
||||
availability7d: 0.85,
|
||||
availability30d: 0.85,
|
||||
indexSchema: 0.9,
|
||||
itemValidity: 0.9,
|
||||
dryRun: 0.9,
|
||||
},
|
||||
registries,
|
||||
} satisfies RegistryHealthSnapshot
|
||||
}
|
||||
|
||||
describe("GET /r/registries.json", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2026-08-24T13:00:00.000Z"))
|
||||
vi.stubEnv("REGISTRY_HEALTH_ENABLED", "1")
|
||||
vi.stubEnv("BLOB_READ_WRITE_TOKEN", "test-token")
|
||||
loadSnapshot.mockReset()
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("uses static ISR so requests do not read Blob directly", () => {
|
||||
expect(dynamic).toBe("force-static")
|
||||
expect(revalidate).toBe(300)
|
||||
})
|
||||
|
||||
it("merges fresh health by exact namespace", async () => {
|
||||
const first = directory[0]
|
||||
loadSnapshot.mockResolvedValue(
|
||||
createSnapshot({ [first.name]: createHealth() })
|
||||
)
|
||||
|
||||
const response = await GET()
|
||||
const payload = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(payload[0]).toMatchObject({
|
||||
name: first.name,
|
||||
homepage: first.homepage,
|
||||
url: first.url,
|
||||
description: first.description,
|
||||
health: {
|
||||
score: 90,
|
||||
statusReason: {
|
||||
code: "healthy_thresholds",
|
||||
message: "Recent checks are within healthy thresholds",
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(loadSnapshot).toHaveBeenCalledWith({
|
||||
token: "test-token",
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
})
|
||||
})
|
||||
|
||||
it("omits health for a registry missing from the snapshot", async () => {
|
||||
loadSnapshot.mockResolvedValue(createSnapshot({}))
|
||||
|
||||
const response = await GET()
|
||||
const payload = await response.json()
|
||||
|
||||
expect(payload[0]).not.toHaveProperty("health")
|
||||
})
|
||||
|
||||
it("returns the original payload when health is disabled", async () => {
|
||||
vi.stubEnv("REGISTRY_HEALTH_ENABLED", "0")
|
||||
|
||||
const response = await GET()
|
||||
const payload = await response.json()
|
||||
|
||||
expect(loadSnapshot).not.toHaveBeenCalled()
|
||||
expect(payload[0]).toEqual({
|
||||
name: directory[0].name,
|
||||
homepage: directory[0].homepage,
|
||||
url: directory[0].url,
|
||||
description: directory[0].description,
|
||||
})
|
||||
})
|
||||
|
||||
it("fails open for a stale snapshot", async () => {
|
||||
vi.setSystemTime(new Date("2026-08-25T00:00:00.000Z"))
|
||||
loadSnapshot.mockResolvedValue(createSnapshot({}))
|
||||
|
||||
const response = await GET()
|
||||
const payload = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(payload[0]).not.toHaveProperty("health")
|
||||
})
|
||||
|
||||
it.each(["a malformed snapshot", "an unknown version", "a Blob 500"])(
|
||||
"fails open for %s",
|
||||
async (failure) => {
|
||||
loadSnapshot.mockRejectedValue(new Error(failure))
|
||||
|
||||
const response = await GET()
|
||||
const payload = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(payload[0]).not.toHaveProperty("health")
|
||||
}
|
||||
)
|
||||
|
||||
it("fails open when the snapshot request times out", async () => {
|
||||
loadSnapshot.mockImplementation(
|
||||
({ abortSignal }) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
abortSignal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const responsePromise = GET()
|
||||
await vi.advanceTimersByTimeAsync(2500)
|
||||
const response = await responsePromise
|
||||
const payload = await response.json()
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(payload[0]).not.toHaveProperty("health")
|
||||
})
|
||||
|
||||
it("fails open when the snapshot is missing", async () => {
|
||||
loadSnapshot.mockResolvedValue(null)
|
||||
|
||||
const response = await GET()
|
||||
const payload = await response.json()
|
||||
|
||||
expect(payload[0]).not.toHaveProperty("health")
|
||||
})
|
||||
})
|
||||
@@ -1,16 +1,63 @@
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
import {
|
||||
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
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
---
|
||||
title: Registry Health
|
||||
description: How public registries are monitored and scored.
|
||||
---
|
||||
|
||||
Registries can change or go offline after they are added to the directory.
|
||||
Registry Health keeps checking them so users can see whether they are working
|
||||
and maintainers can catch problems early.
|
||||
|
||||
We check that a registry is online, follows the registry format, and works with
|
||||
the `shadcn` CLI. These checks are combined into a status and a score.
|
||||
|
||||
<Callout title="Registry Health is experimental and not live yet.">
|
||||
Scores are collected but do not yet affect registry visibility or ordering.
|
||||
The scoring model and its thresholds may change before launch.
|
||||
</Callout>
|
||||
|
||||
**Registry Health only applies to registries listed in the shadcn/ui Registry
|
||||
Directory.** It does not monitor or affect private registries, registries
|
||||
configured directly in your project, or GitHub registries used through
|
||||
`owner/repo/item` addresses.
|
||||
|
||||
Monitoring starts after a registry is published. It does not decide whether a
|
||||
registry can be added, and failed checks do not unpublish it. A future version
|
||||
of the Registry Directory may hide an unavailable registry from normal
|
||||
browsing without removing it from the API.
|
||||
|
||||
## What we check
|
||||
|
||||
| Cadence | Check | What we look for |
|
||||
| ------- | -------------- | ----------------------------------------------------------- |
|
||||
| Hourly | Registry index | The index is online, valid, and correctly configured. |
|
||||
| Daily | Registry items | A rotating sample of items can be downloaded and validated. |
|
||||
| Weekly | CLI | A rotating item works with `shadcn add --dry-run`. |
|
||||
|
||||
Item checks rotate through the catalog over approximately 30 days. Scheduled
|
||||
runs are best-effort, so we use the time of each real observation instead of
|
||||
assuming that every scheduled check ran.
|
||||
|
||||
## Status
|
||||
|
||||
The status tells you how a registry is doing right now:
|
||||
|
||||
| Status | What it means |
|
||||
| --------------- | ------------------------------------------------------------------------- |
|
||||
| **Observing** | We are collecting the first 24 index checks over at least 24 hours. |
|
||||
| **Healthy** | The initial observation period is complete and recent checks are passing. |
|
||||
| **Degraded** | The registry is online, but one or more recent checks are failing. |
|
||||
| **Unavailable** | The registry index has not passed a check for at least 24 hours. |
|
||||
|
||||
A registry can become **Unavailable** before the initial observation period is
|
||||
complete. We mark a registry as **Degraded** when:
|
||||
|
||||
- The index fails three checks in a row.
|
||||
- The latest index does not match the registry schema.
|
||||
- Fewer than 90% of sampled items pass after at least 10 checks.
|
||||
- The two most recent CLI checks fail.
|
||||
|
||||
The status can react to a recent problem before the overall score changes much.
|
||||
This means a registry can have a high score and still be **Degraded**.
|
||||
|
||||
Each status includes a short, human-readable reason. API consumers can use the
|
||||
stable `statusReason.code` or display `statusReason.message`. Raw errors from
|
||||
the monitor are not published.
|
||||
|
||||
After an availability failure, a registry needs two successful index and schema
|
||||
checks in a row to recover. Other degradations clear when their failing checks
|
||||
return to healthy levels.
|
||||
|
||||
## Score
|
||||
|
||||
Every registry receives one score out of **100 points**. It is the sum of four
|
||||
components. The component values are not separate scores out of 100.
|
||||
|
||||
| Component | Points | What it measures |
|
||||
| -------------- | -----: | ----------------------------------------------------------------------- |
|
||||
| Reliability | 45 | How often the registry index was available over the last 7 and 30 days. |
|
||||
| Correctness | 25 | Whether the index and sampled items match the registry schema. |
|
||||
| Installability | 20 | Whether sampled items work with the `shadcn` CLI. |
|
||||
| Registry setup | 10 | HTTPS, JSON responses, unique item names, and a matching registry name. |
|
||||
|
||||
The score is about reliability and compatibility. It is not a measure of
|
||||
popularity, code quality, design quality, or how many items a registry contains.
|
||||
|
||||
### How the points are calculated
|
||||
|
||||
Recent availability matters more than older availability:
|
||||
|
||||
```text
|
||||
Reliability = 45 * (0.65 * availability7d + 0.35 * availability30d)
|
||||
```
|
||||
|
||||
Correctness gives up to 10 points for the index and 15 points for sampled items:
|
||||
|
||||
```text
|
||||
Correctness = 10 * indexSchemaPassRate30d
|
||||
+ 15 * sampledItemPassRate30d
|
||||
```
|
||||
|
||||
Installability is based on CLI checks:
|
||||
|
||||
```text
|
||||
Installability = 20 * dryRunPassRate30d
|
||||
```
|
||||
|
||||
Registry setup has four checks worth 2.5 points each. If we have not observed a
|
||||
setup signal yet, it receives 1.25 points until it can be checked.
|
||||
|
||||
Each component is rounded to three decimal places. The published score is the
|
||||
sum of those rounded values.
|
||||
|
||||
## Scores for new registries
|
||||
|
||||
A new registry does not have enough history for a reliable score. We blend its
|
||||
early results with the average across monitored registries. As more checks are
|
||||
collected, the registry's own results have more influence.
|
||||
|
||||
This prevents one successful check from producing a perfect score and one
|
||||
failed check from producing a zero. It also explains why an **Observing**
|
||||
registry can already have a score close to the overall average.
|
||||
|
||||
For API consumers, the smoothing formula is:
|
||||
|
||||
```text
|
||||
(successes + globalMean * priorWeight) / (observations + priorWeight)
|
||||
```
|
||||
|
||||
The prior weight follows the cadence of each check:
|
||||
|
||||
| Signal | Prior weight |
|
||||
| --------------------- | -----------: |
|
||||
| Availability | 24 |
|
||||
| Index schema validity | 12 |
|
||||
| Sampled item validity | 10 |
|
||||
| CLI checks | 3 |
|
||||
|
||||
When there is not enough registry-wide data to calculate an average, we start
|
||||
with an 85% availability prior and a 90% prior for the other measured rates. A
|
||||
change to the formula, weights, or thresholds requires a new `scoreVersion`.
|
||||
|
||||
## Health data in the API
|
||||
|
||||
`/r/registries.json` adds an optional `health` object to each registry. It
|
||||
includes:
|
||||
|
||||
- The current `status` and `statusReason`.
|
||||
- The overall `score` and its component `breakdown`.
|
||||
- Smoothed `availability7d` and `availability30d` rates from 0 to 1.
|
||||
- `firstObservedAt`, `checkedAt`, and `lastSuccessfulCheck` timestamps.
|
||||
- `schemaVersion` and `scoreVersion` for integrations.
|
||||
|
||||
The object also includes two flags:
|
||||
|
||||
- `monitoringLimited` means the latest registry index request was blocked by a
|
||||
CDN or WAF challenge. Challenge responses do not count as availability or
|
||||
sampled item validation failures.
|
||||
- `hidden` becomes `true` after seven continuous days of unavailability.
|
||||
|
||||
The Registry Directory does not currently use the `hidden` flag.
|
||||
|
||||
## Monitoring limitations
|
||||
|
||||
All checks come from one hosted runner. Latency is therefore kept as a private
|
||||
diagnostic and does not affect the score because some regions would have an
|
||||
unfair advantage.
|
||||
|
||||
CDN and WAF challenges are also handled separately. A challenge tells us that
|
||||
our runner could not complete the check, not that the registry is unavailable
|
||||
to everyone.
|
||||
|
||||
## What happens next
|
||||
|
||||
We will collect and review baseline data before using Registry Health in the
|
||||
Directory. Ranking, filtering, and health UI will ship separately once the
|
||||
monitoring data is reliable.
|
||||
@@ -5,6 +5,7 @@
|
||||
"getting-started",
|
||||
"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.
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const registryNamespaceSchema = z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9_-]*$/)
|
||||
|
||||
const registryDirectoryEntrySchema = z
|
||||
.object({
|
||||
name: registryNamespaceSchema,
|
||||
homepage: z.string().url(),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((url) => url.includes("{name}"), {
|
||||
message: "URL must include {name} placeholder",
|
||||
}),
|
||||
description: z.string(),
|
||||
author: z.string().optional(),
|
||||
logo: z.string(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
const registryDirectorySchema = z
|
||||
.array(registryDirectoryEntrySchema)
|
||||
.superRefine((entries, context) => {
|
||||
const names = new Set<string>()
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const name = entry.name.toLowerCase()
|
||||
if (names.has(name)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Duplicate registry namespace: ${entry.name}`,
|
||||
path: [index, "name"],
|
||||
})
|
||||
}
|
||||
names.add(name)
|
||||
})
|
||||
})
|
||||
|
||||
const publicRegistryDirectoryEntrySchema = registryDirectoryEntrySchema.pick({
|
||||
name: true,
|
||||
homepage: true,
|
||||
url: true,
|
||||
description: true,
|
||||
})
|
||||
|
||||
const publicRegistryDirectorySchema = z.array(
|
||||
publicRegistryDirectoryEntrySchema
|
||||
)
|
||||
|
||||
type RegistryDirectoryEntry = z.infer<typeof registryDirectoryEntrySchema>
|
||||
|
||||
function createPublicRegistryDirectory(
|
||||
entries: readonly RegistryDirectoryEntry[]
|
||||
) {
|
||||
return entries.map(({ name, homepage, url, description }) => ({
|
||||
name,
|
||||
homepage,
|
||||
url,
|
||||
description,
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeRegistryName(value: string) {
|
||||
return value.toLowerCase().replaceAll(" ", "").replace(/^@/, "")
|
||||
}
|
||||
|
||||
export {
|
||||
createPublicRegistryDirectory,
|
||||
normalizeRegistryName,
|
||||
publicRegistryDirectoryEntrySchema,
|
||||
publicRegistryDirectorySchema,
|
||||
registryDirectoryEntrySchema,
|
||||
registryDirectorySchema,
|
||||
registryNamespaceSchema,
|
||||
}
|
||||
export type { RegistryDirectoryEntry }
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
cleanRegistryHealthHistory,
|
||||
loadRegistryHealthSnapshot,
|
||||
loadRegistryMonitorState,
|
||||
publishRegistryHealth,
|
||||
type BlobOperations,
|
||||
} from "./blob"
|
||||
import type {
|
||||
RegistryHealthSnapshot,
|
||||
RegistryMonitorRun,
|
||||
RegistryMonitorState,
|
||||
} from "./schema"
|
||||
|
||||
function streamJson(value: unknown) {
|
||||
const bytes = new TextEncoder().encode(JSON.stringify(value))
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(bytes)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function createOperations(overrides: Partial<BlobOperations> = {}) {
|
||||
return {
|
||||
get: vi.fn(async () => null),
|
||||
put: vi.fn(async (pathname) => ({
|
||||
url: `https://blob.example.com/${pathname}`,
|
||||
})),
|
||||
list: vi.fn(async () => ({ blobs: [], hasMore: false })),
|
||||
del: vi.fn(async () => {}),
|
||||
...overrides,
|
||||
} satisfies BlobOperations
|
||||
}
|
||||
|
||||
const STATE: RegistryMonitorState = {
|
||||
schemaVersion: 1,
|
||||
scoreVersion: 1,
|
||||
updatedAt: "2026-08-24T12:00:00.000Z",
|
||||
registries: {},
|
||||
}
|
||||
|
||||
const SNAPSHOT: RegistryHealthSnapshot = {
|
||||
schemaVersion: 1,
|
||||
scoreVersion: 1,
|
||||
generatedAt: "2026-08-24T12:00:00.000Z",
|
||||
globalMeans: {
|
||||
availability7d: 0.85,
|
||||
availability30d: 0.85,
|
||||
indexSchema: 0.9,
|
||||
itemValidity: 0.9,
|
||||
dryRun: 0.9,
|
||||
},
|
||||
registries: {},
|
||||
}
|
||||
|
||||
const RUN: RegistryMonitorRun = {
|
||||
schemaVersion: 1,
|
||||
startedAt: "2026-08-24T12:00:00.000Z",
|
||||
completedAt: "2026-08-24T12:01:00.000Z",
|
||||
mode: "hourly",
|
||||
totals: {
|
||||
registries: 0,
|
||||
reachable: 0,
|
||||
unavailable: 0,
|
||||
challenges: 0,
|
||||
itemChecks: 0,
|
||||
dryRuns: 0,
|
||||
},
|
||||
results: {},
|
||||
diagnostics: [],
|
||||
}
|
||||
|
||||
describe("loadRegistryMonitorState", () => {
|
||||
it("returns null on the first run", async () => {
|
||||
const state = await loadRegistryMonitorState({
|
||||
token: "test-token",
|
||||
operations: createOperations(),
|
||||
})
|
||||
|
||||
expect(state).toBeNull()
|
||||
})
|
||||
|
||||
it("validates stored state before using it", async () => {
|
||||
const operations = createOperations({
|
||||
get: vi.fn(async () => ({ stream: streamJson(STATE) })),
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadRegistryMonitorState({ token: "test-token", operations })
|
||||
).resolves.toEqual(STATE)
|
||||
expect(operations.get).toHaveBeenCalledWith(
|
||||
"registry-health/v1/state.json",
|
||||
{
|
||||
access: "private",
|
||||
token: "test-token",
|
||||
useCache: false,
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadRegistryHealthSnapshot", () => {
|
||||
it("reads and validates the private latest snapshot", async () => {
|
||||
const abortController = new AbortController()
|
||||
const operations = createOperations({
|
||||
get: vi.fn(async () => ({ stream: streamJson(SNAPSHOT) })),
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadRegistryHealthSnapshot({
|
||||
token: "test-token",
|
||||
abortSignal: abortController.signal,
|
||||
operations,
|
||||
})
|
||||
).resolves.toEqual(SNAPSHOT)
|
||||
expect(operations.get).toHaveBeenCalledWith(
|
||||
"registry-health/v1/latest.json",
|
||||
{
|
||||
access: "private",
|
||||
token: "test-token",
|
||||
useCache: false,
|
||||
abortSignal: abortController.signal,
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects an invalid latest snapshot", async () => {
|
||||
const operations = createOperations({
|
||||
get: vi.fn(async () => ({
|
||||
stream: streamJson({ ...SNAPSHOT, schemaVersion: 2 }),
|
||||
})),
|
||||
})
|
||||
|
||||
await expect(
|
||||
loadRegistryHealthSnapshot({ token: "test-token", operations })
|
||||
).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("publishRegistryHealth", () => {
|
||||
it("publishes immutable history before overwriting state and latest", async () => {
|
||||
const operations = createOperations()
|
||||
const result = await publishRegistryHealth({
|
||||
state: STATE,
|
||||
snapshot: SNAPSHOT,
|
||||
run: RUN,
|
||||
token: "test-token",
|
||||
operations,
|
||||
})
|
||||
const calls = vi.mocked(operations.put).mock.calls
|
||||
|
||||
expect(calls.map(([pathname]) => pathname)).toEqual([
|
||||
"registry-health/v1/runs/2026-08-24T12-00-00-000Z.json",
|
||||
"registry-health/v1/daily/2026-08-24.json",
|
||||
"registry-health/v1/state.json",
|
||||
"registry-health/v1/latest.json",
|
||||
])
|
||||
expect(calls[0][2].allowOverwrite).toBe(false)
|
||||
expect(calls[2][2].allowOverwrite).toBe(true)
|
||||
expect(calls[3][2].allowOverwrite).toBe(true)
|
||||
expect(calls.every(([, , options]) => options.access === "private")).toBe(
|
||||
true
|
||||
)
|
||||
expect(result.latestPath).toBe("registry-health/v1/latest.json")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cleanRegistryHealthHistory", () => {
|
||||
it("paginates listings and deletes expired history", async () => {
|
||||
const list = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
blobs: [
|
||||
{
|
||||
pathname: "registry-health/v1/runs/old.json",
|
||||
uploadedAt: new Date("2026-08-01T00:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
cursor: "next",
|
||||
hasMore: true,
|
||||
})
|
||||
.mockResolvedValueOnce({ blobs: [], hasMore: false })
|
||||
.mockResolvedValueOnce({ blobs: [], hasMore: false })
|
||||
const operations = createOperations({ list })
|
||||
|
||||
const result = await cleanRegistryHealthHistory({
|
||||
token: "test-token",
|
||||
now: new Date("2026-08-24T12:00:00.000Z"),
|
||||
operations,
|
||||
})
|
||||
|
||||
expect(list).toHaveBeenCalledTimes(3)
|
||||
expect(operations.del).toHaveBeenCalledWith(
|
||||
["registry-health/v1/runs/old.json"],
|
||||
{ token: "test-token" }
|
||||
)
|
||||
expect(result.deleted).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
del as deleteBlob,
|
||||
get as getBlob,
|
||||
list as listBlobs,
|
||||
put as putBlob,
|
||||
} from "@vercel/blob"
|
||||
|
||||
import {
|
||||
registryHealthSnapshotSchema,
|
||||
registryMonitorStateSchema,
|
||||
type RegistryHealthSnapshot,
|
||||
type RegistryMonitorRun,
|
||||
type RegistryMonitorState,
|
||||
} from "./schema"
|
||||
|
||||
const BLOB_PREFIX = "registry-health/v1"
|
||||
const STATE_PATH = `${BLOB_PREFIX}/state.json`
|
||||
const LATEST_PATH = `${BLOB_PREFIX}/latest.json`
|
||||
|
||||
type BlobListItem = {
|
||||
pathname: string
|
||||
uploadedAt: Date
|
||||
}
|
||||
|
||||
type BlobOperations = {
|
||||
get: (
|
||||
pathname: string,
|
||||
options: {
|
||||
access: "private"
|
||||
token?: string
|
||||
useCache: false
|
||||
abortSignal?: AbortSignal
|
||||
}
|
||||
) => Promise<{ stream: ReadableStream<Uint8Array> | null } | null>
|
||||
put: (
|
||||
pathname: string,
|
||||
body: string,
|
||||
options: {
|
||||
access: "private"
|
||||
token?: string
|
||||
addRandomSuffix: false
|
||||
allowOverwrite?: boolean
|
||||
cacheControlMaxAge: number
|
||||
contentType: "application/json"
|
||||
}
|
||||
) => Promise<{ url: string }>
|
||||
list: (options: {
|
||||
prefix: string
|
||||
cursor?: string
|
||||
limit: number
|
||||
token?: string
|
||||
}) => Promise<{
|
||||
blobs: BlobListItem[]
|
||||
cursor?: string
|
||||
hasMore: boolean
|
||||
}>
|
||||
del: (pathnames: string[], options: { token?: string }) => Promise<void>
|
||||
}
|
||||
|
||||
const defaultOperations: BlobOperations = {
|
||||
get: getBlob,
|
||||
put: putBlob,
|
||||
list: listBlobs,
|
||||
del: deleteBlob,
|
||||
}
|
||||
|
||||
async function readJsonStream(stream: ReadableStream<Uint8Array>) {
|
||||
const reader = stream.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
chunks.push(value)
|
||||
size += value.byteLength
|
||||
}
|
||||
|
||||
const body = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
|
||||
return JSON.parse(new TextDecoder().decode(body)) as unknown
|
||||
}
|
||||
|
||||
export async function loadRegistryMonitorState({
|
||||
token,
|
||||
operations = defaultOperations,
|
||||
}: {
|
||||
token?: string
|
||||
operations?: BlobOperations
|
||||
}) {
|
||||
const result = await operations.get(STATE_PATH, {
|
||||
access: "private",
|
||||
token,
|
||||
useCache: false,
|
||||
})
|
||||
|
||||
if (!result?.stream) return null
|
||||
|
||||
return registryMonitorStateSchema.parse(await readJsonStream(result.stream))
|
||||
}
|
||||
|
||||
export async function loadRegistryHealthSnapshot({
|
||||
token,
|
||||
abortSignal,
|
||||
operations = defaultOperations,
|
||||
}: {
|
||||
token?: string
|
||||
abortSignal?: AbortSignal
|
||||
operations?: BlobOperations
|
||||
}) {
|
||||
const result = await operations.get(LATEST_PATH, {
|
||||
access: "private",
|
||||
token,
|
||||
useCache: false,
|
||||
abortSignal,
|
||||
})
|
||||
|
||||
if (!result?.stream) return null
|
||||
|
||||
return registryHealthSnapshotSchema.parse(await readJsonStream(result.stream))
|
||||
}
|
||||
|
||||
function getRunPath(run: RegistryMonitorRun) {
|
||||
const timestamp = run.startedAt.replaceAll(":", "-").replaceAll(".", "-")
|
||||
return `${BLOB_PREFIX}/runs/${timestamp}.json`
|
||||
}
|
||||
|
||||
function getDailyPath(snapshot: RegistryHealthSnapshot) {
|
||||
return `${BLOB_PREFIX}/daily/${snapshot.generatedAt.slice(0, 10)}.json`
|
||||
}
|
||||
|
||||
function getDailyDocument(
|
||||
state: RegistryMonitorState,
|
||||
snapshot: RegistryHealthSnapshot
|
||||
) {
|
||||
const date = snapshot.generatedAt.slice(0, 10)
|
||||
return {
|
||||
schemaVersion: snapshot.schemaVersion,
|
||||
scoreVersion: snapshot.scoreVersion,
|
||||
date,
|
||||
generatedAt: snapshot.generatedAt,
|
||||
globalMeans: snapshot.globalMeans,
|
||||
registries: Object.fromEntries(
|
||||
Object.entries(state.registries).map(([name, entry]) => [
|
||||
name,
|
||||
{
|
||||
aggregate: entry.daily.find((bucket) => bucket.date === date) ?? null,
|
||||
health: snapshot.registries[name],
|
||||
},
|
||||
])
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
async function putJson({
|
||||
pathname,
|
||||
value,
|
||||
token,
|
||||
allowOverwrite = false,
|
||||
operations,
|
||||
}: {
|
||||
pathname: string
|
||||
value: unknown
|
||||
token?: string
|
||||
allowOverwrite?: boolean
|
||||
operations: BlobOperations
|
||||
}) {
|
||||
return operations.put(pathname, JSON.stringify(value), {
|
||||
access: "private",
|
||||
token,
|
||||
addRandomSuffix: false,
|
||||
allowOverwrite,
|
||||
cacheControlMaxAge: 60,
|
||||
contentType: "application/json",
|
||||
})
|
||||
}
|
||||
|
||||
export async function publishRegistryHealth({
|
||||
state,
|
||||
snapshot,
|
||||
run,
|
||||
token,
|
||||
operations = defaultOperations,
|
||||
}: {
|
||||
state: RegistryMonitorState
|
||||
snapshot: RegistryHealthSnapshot
|
||||
run: RegistryMonitorRun
|
||||
token?: string
|
||||
operations?: BlobOperations
|
||||
}) {
|
||||
await putJson({
|
||||
pathname: getRunPath(run),
|
||||
value: run,
|
||||
token,
|
||||
operations,
|
||||
})
|
||||
await putJson({
|
||||
pathname: getDailyPath(snapshot),
|
||||
value: getDailyDocument(state, snapshot),
|
||||
token,
|
||||
allowOverwrite: true,
|
||||
operations,
|
||||
})
|
||||
await putJson({
|
||||
pathname: STATE_PATH,
|
||||
value: state,
|
||||
token,
|
||||
allowOverwrite: true,
|
||||
operations,
|
||||
})
|
||||
await putJson({
|
||||
pathname: LATEST_PATH,
|
||||
value: snapshot,
|
||||
token,
|
||||
allowOverwrite: true,
|
||||
operations,
|
||||
})
|
||||
|
||||
return { latestPath: LATEST_PATH }
|
||||
}
|
||||
|
||||
async function listAll({
|
||||
prefix,
|
||||
token,
|
||||
operations,
|
||||
}: {
|
||||
prefix: string
|
||||
token?: string
|
||||
operations: BlobOperations
|
||||
}) {
|
||||
const blobs: BlobListItem[] = []
|
||||
let cursor: string | undefined
|
||||
|
||||
do {
|
||||
const page = await operations.list({
|
||||
prefix,
|
||||
cursor,
|
||||
limit: 1000,
|
||||
token,
|
||||
})
|
||||
blobs.push(...page.blobs)
|
||||
cursor = page.hasMore ? page.cursor : undefined
|
||||
} while (cursor)
|
||||
|
||||
return blobs
|
||||
}
|
||||
|
||||
export async function cleanRegistryHealthHistory({
|
||||
token,
|
||||
now = new Date(),
|
||||
operations = defaultOperations,
|
||||
}: {
|
||||
token?: string
|
||||
now?: Date
|
||||
operations?: BlobOperations
|
||||
}) {
|
||||
const policies = [
|
||||
{ prefix: `${BLOB_PREFIX}/runs/`, retentionDays: 14 },
|
||||
{ prefix: `${BLOB_PREFIX}/daily/`, retentionDays: 90 },
|
||||
]
|
||||
const stale: string[] = []
|
||||
|
||||
for (const policy of policies) {
|
||||
const blobs = await listAll({
|
||||
prefix: policy.prefix,
|
||||
token,
|
||||
operations,
|
||||
})
|
||||
const cutoff = now.getTime() - policy.retentionDays * 24 * 60 * 60 * 1000
|
||||
stale.push(
|
||||
...blobs
|
||||
.filter((blob) => blob.uploadedAt.getTime() < cutoff)
|
||||
.map((blob) => blob.pathname)
|
||||
)
|
||||
}
|
||||
|
||||
for (let index = 0; index < stale.length; index += 100) {
|
||||
await operations.del(stale.slice(index, index + 100), { token })
|
||||
}
|
||||
|
||||
return { deleted: stale.length }
|
||||
}
|
||||
|
||||
export { BLOB_PREFIX, LATEST_PATH, STATE_PATH }
|
||||
export type { BlobOperations }
|
||||
@@ -0,0 +1,53 @@
|
||||
import { promises as fs } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { getDryRunEnvironment, runLocalCliDryRun } from "./dry-run"
|
||||
|
||||
describe("getDryRunEnvironment", () => {
|
||||
it("allowlists process variables and excludes credentials", () => {
|
||||
const environment = getDryRunEnvironment("/tmp/registry-health-test")
|
||||
|
||||
expect(environment).toMatchObject({
|
||||
CI: "1",
|
||||
NO_COLOR: "1",
|
||||
XDG_CACHE_HOME: "/tmp/registry-health-test",
|
||||
XDG_CONFIG_HOME: "/tmp/registry-health-test",
|
||||
})
|
||||
expect(environment).not.toHaveProperty("BLOB_READ_WRITE_TOKEN")
|
||||
expect(environment).not.toHaveProperty("GITHUB_TOKEN")
|
||||
expect(environment).not.toHaveProperty("GH_TOKEN")
|
||||
})
|
||||
})
|
||||
|
||||
describe("runLocalCliDryRun", () => {
|
||||
it("force kills a CLI process that ignores SIGTERM", async () => {
|
||||
const directory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "registry-health-cli-test-")
|
||||
)
|
||||
const cliPath = path.join(directory, "cli.mjs")
|
||||
await fs.writeFile(
|
||||
cliPath,
|
||||
'process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await runLocalCliDryRun({
|
||||
namespace: "@acme",
|
||||
item: "button",
|
||||
registryUrl: "https://acme.example.com/r/{name}.json",
|
||||
cliPath,
|
||||
timeoutMs: 25,
|
||||
killGraceMs: 25,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
failureCode: "timeout",
|
||||
})
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { spawn, type ChildProcess } from "node:child_process"
|
||||
import { promises as fs } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
|
||||
import type { RegistryDryRunResult } from "./monitor"
|
||||
|
||||
async function runLocalCliDryRun({
|
||||
namespace,
|
||||
item,
|
||||
registryUrl,
|
||||
cliPath,
|
||||
timeoutMs = 60_000,
|
||||
killGraceMs = 5_000,
|
||||
}: {
|
||||
namespace: string
|
||||
item: string
|
||||
registryUrl: string
|
||||
cliPath: string
|
||||
timeoutMs?: number
|
||||
killGraceMs?: number
|
||||
}): Promise<RegistryDryRunResult> {
|
||||
const startedAt = Date.now()
|
||||
const temporaryDirectory = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "shadcn-registry-health-")
|
||||
)
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.join(temporaryDirectory, "app"), { recursive: true })
|
||||
await Promise.all([
|
||||
fs.writeFile(
|
||||
path.join(temporaryDirectory, "components.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: "new-york",
|
||||
rsc: true,
|
||||
tsx: true,
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "app/globals.css",
|
||||
baseColor: "neutral",
|
||||
cssVariables: true,
|
||||
prefix: "",
|
||||
},
|
||||
iconLibrary: "lucide",
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
ui: "@/components/ui",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
},
|
||||
registries: {
|
||||
[namespace]: registryUrl,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
),
|
||||
fs.writeFile(path.join(temporaryDirectory, "app/globals.css"), ""),
|
||||
fs.writeFile(
|
||||
path.join(temporaryDirectory, "package.json"),
|
||||
JSON.stringify({ private: true })
|
||||
),
|
||||
])
|
||||
|
||||
return await new Promise<RegistryDryRunResult>((resolve) => {
|
||||
const child: ChildProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
cliPath,
|
||||
"add",
|
||||
`${namespace}/${item}`,
|
||||
"--dry-run",
|
||||
"--yes",
|
||||
"--cwd",
|
||||
temporaryDirectory,
|
||||
],
|
||||
{
|
||||
cwd: temporaryDirectory,
|
||||
env: getDryRunEnvironment(temporaryDirectory),
|
||||
stdio: "ignore",
|
||||
}
|
||||
)
|
||||
let completed = false
|
||||
let timedOut = false
|
||||
let forceKillTimeout: NodeJS.Timeout | undefined
|
||||
const finish = (result: RegistryDryRunResult) => {
|
||||
if (completed) return
|
||||
completed = true
|
||||
clearTimeout(timeout)
|
||||
if (forceKillTimeout) clearTimeout(forceKillTimeout)
|
||||
resolve(result)
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill("SIGTERM")
|
||||
forceKillTimeout = setTimeout(() => {
|
||||
if (!completed) child.kill("SIGKILL")
|
||||
}, killGraceMs)
|
||||
}, timeoutMs)
|
||||
|
||||
child.on("error", () =>
|
||||
finish({
|
||||
success: false,
|
||||
failureCode: timedOut ? "timeout" : "spawn_error",
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
)
|
||||
child.on("exit", (code, signal) => {
|
||||
const failureCode = timedOut
|
||||
? "timeout"
|
||||
: code === 0
|
||||
? undefined
|
||||
: signal
|
||||
? "terminated"
|
||||
: `exit_${code}`
|
||||
|
||||
finish({
|
||||
success: !timedOut && code === 0,
|
||||
failureCode,
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
await fs.rm(temporaryDirectory, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
function getDryRunEnvironment(temporaryDirectory: string) {
|
||||
return {
|
||||
PATH: process.env.PATH,
|
||||
TMPDIR: os.tmpdir(),
|
||||
XDG_CACHE_HOME: temporaryDirectory,
|
||||
XDG_CONFIG_HOME: temporaryDirectory,
|
||||
npm_config_userconfig: path.join(temporaryDirectory, ".npmrc"),
|
||||
NODE_ENV: "production",
|
||||
CI: "1",
|
||||
NO_COLOR: "1",
|
||||
} satisfies NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export { getDryRunEnvironment, runLocalCliDryRun }
|
||||
@@ -0,0 +1,613 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import type { RegistryDirectoryEntry } from "../registry-directory"
|
||||
import { runRegistryMonitor } from "./monitor"
|
||||
import type { RegistryMonitorEntryState, RegistryMonitorState } from "./schema"
|
||||
|
||||
const NOW = new Date("2026-08-24T12:00:00.000Z")
|
||||
const DIRECTORY: RegistryDirectoryEntry[] = [
|
||||
{
|
||||
name: "@acme",
|
||||
homepage: "https://acme.example.com",
|
||||
url: "https://acme.example.com/r/{name}.json",
|
||||
description: "Acme components",
|
||||
logo: "https://acme.example.com/logo.svg",
|
||||
},
|
||||
]
|
||||
function createEntryState(overrides: Partial<RegistryMonitorEntryState> = {}) {
|
||||
return {
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: "2026-08-24T10:00:00.000Z",
|
||||
status: "healthy",
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityRecoveryRequired: false,
|
||||
itemCursor: 0,
|
||||
itemNames: [],
|
||||
recentIndex: [],
|
||||
recentDryRuns: [],
|
||||
daily: [],
|
||||
latestHygiene: {
|
||||
contentTypeJson: null,
|
||||
noDuplicateNames: null,
|
||||
nameMatches: null,
|
||||
},
|
||||
...overrides,
|
||||
} satisfies RegistryMonitorEntryState
|
||||
}
|
||||
|
||||
function createState(entry = createEntryState()) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
scoreVersion: 1,
|
||||
updatedAt: "2026-08-24T10:00:00.000Z",
|
||||
registries: { "@acme": entry },
|
||||
} satisfies RegistryMonitorState
|
||||
}
|
||||
|
||||
function createSuccessfulFetch(itemCount = 1) {
|
||||
return {
|
||||
ok: true as const,
|
||||
json: {
|
||||
name: "acme",
|
||||
homepage: "https://acme.example.com",
|
||||
items: Array.from({ length: itemCount }, (_, index) => ({
|
||||
name: `item-${index}`,
|
||||
type: "registry:ui",
|
||||
files: [],
|
||||
})),
|
||||
},
|
||||
status: 200,
|
||||
durationMs: 120,
|
||||
responseSize: 500,
|
||||
contentType: "application/json; charset=utf-8",
|
||||
redirectCount: 0,
|
||||
finalUrl: "https://acme.example.com/r/registry.json",
|
||||
}
|
||||
}
|
||||
|
||||
describe("runRegistryMonitor", () => {
|
||||
it("records completion separately from the observation time", async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2026-08-24T12:05:00.000Z"))
|
||||
|
||||
try {
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
mode: "hourly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result.run.startedAt).toBe(NOW.toISOString())
|
||||
expect(result.run.completedAt).toBe("2026-08-24T12:05:00.000Z")
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it("records a successful hourly index observation", async () => {
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
mode: "hourly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => createSuccessfulFetch(2)),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result.run.totals).toMatchObject({
|
||||
registries: 1,
|
||||
reachable: 1,
|
||||
unavailable: 0,
|
||||
challenges: 0,
|
||||
})
|
||||
expect(result.state.registries["@acme"].itemNames).toEqual([
|
||||
"item-0",
|
||||
"item-1",
|
||||
])
|
||||
expect(result.snapshot.registries["@acme"]).toMatchObject({
|
||||
status: "observing",
|
||||
monitoringLimited: false,
|
||||
checkedAt: NOW.toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it("starts an outage from the last successful index check", async () => {
|
||||
const lastSuccessfulCheck = "2026-08-23T11:00:00.000Z"
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState: createState(
|
||||
createEntryState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck,
|
||||
consecutiveSuccessfulIndexes: 24,
|
||||
})
|
||||
),
|
||||
mode: "hourly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
failureCode: "timeout",
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
finalUrl: "https://acme.example.com/r/registry.json",
|
||||
})),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result.state.registries["@acme"]).toMatchObject({
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 1,
|
||||
availabilityOutageSince: lastSuccessfulCheck,
|
||||
availabilityRecoveryRequired: true,
|
||||
})
|
||||
expect(result.snapshot.registries["@acme"].status).toBe("unavailable")
|
||||
})
|
||||
|
||||
it("clears availability recovery after two valid index checks", async () => {
|
||||
const outageSince = "2026-08-16T00:00:00.000Z"
|
||||
const recentIndex = Array.from({ length: 24 }, (_, index) => ({
|
||||
checkedAt: new Date(
|
||||
NOW.getTime() - (24 - index) * 60 * 60 * 1000
|
||||
).toISOString(),
|
||||
outcome: "unreachable" as const,
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
}))
|
||||
const previousState = createState(
|
||||
createEntryState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: "2026-08-15T23:00:00.000Z",
|
||||
status: "unavailable",
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 24,
|
||||
availabilityOutageSince: outageSince,
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex,
|
||||
})
|
||||
)
|
||||
|
||||
const first = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState,
|
||||
mode: "hourly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
const recovering = first.state.registries["@acme"]
|
||||
|
||||
expect(recovering).toMatchObject({
|
||||
consecutiveSuccessfulIndexes: 1,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityOutageSince: outageSince,
|
||||
availabilityRecoveryRequired: true,
|
||||
})
|
||||
expect(first.snapshot.registries["@acme"]).toMatchObject({
|
||||
status: "unavailable",
|
||||
hidden: true,
|
||||
})
|
||||
|
||||
const second = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState: first.state,
|
||||
mode: "hourly",
|
||||
now: new Date("2026-08-24T13:00:00.000Z"),
|
||||
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
const recovered = second.state.registries["@acme"]
|
||||
|
||||
expect(recovered).toMatchObject({
|
||||
consecutiveSuccessfulIndexes: 2,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityRecoveryRequired: false,
|
||||
})
|
||||
expect(recovered).not.toHaveProperty("availabilityOutageSince")
|
||||
expect(second.snapshot.registries["@acme"].status).toBe("healthy")
|
||||
})
|
||||
|
||||
it("records a challenge without lowering availability", async () => {
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState: createState(),
|
||||
mode: "hourly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
failureCode: "bot_challenge",
|
||||
reachable: false,
|
||||
botChallenge: true,
|
||||
status: 403,
|
||||
durationMs: 80,
|
||||
responseSize: 200,
|
||||
contentType: "text/html",
|
||||
redirectCount: 0,
|
||||
finalUrl: "https://acme.example.com/r/registry.json",
|
||||
})),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
const bucket = result.state.registries["@acme"].daily[0]
|
||||
const state = result.state.registries["@acme"]
|
||||
expect(bucket.challengeObservations).toBe(1)
|
||||
expect(bucket.availabilityObservations).toBe(0)
|
||||
expect(state).toMatchObject({
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityRecoveryRequired: false,
|
||||
})
|
||||
expect(state).not.toHaveProperty("availabilityOutageSince")
|
||||
expect(result.snapshot.registries["@acme"].monitoringLimited).toBe(true)
|
||||
})
|
||||
|
||||
it("allows an existing outage to become unavailable during a challenge", async () => {
|
||||
const outageSince = "2026-08-23T11:00:00.000Z"
|
||||
const previousState = createState(
|
||||
createEntryState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: outageSince,
|
||||
consecutiveIndexFailures: 1,
|
||||
availabilityOutageSince: outageSince,
|
||||
recentIndex: [
|
||||
{
|
||||
checkedAt: outageSince,
|
||||
outcome: "unreachable",
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState,
|
||||
mode: "hourly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
failureCode: "bot_challenge",
|
||||
reachable: false,
|
||||
botChallenge: true,
|
||||
status: 403,
|
||||
durationMs: 80,
|
||||
responseSize: 200,
|
||||
contentType: "text/html",
|
||||
redirectCount: 0,
|
||||
finalUrl: "https://acme.example.com/r/registry.json",
|
||||
})),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result.state.registries["@acme"]).toMatchObject({
|
||||
consecutiveIndexFailures: 1,
|
||||
availabilityOutageSince: outageSince,
|
||||
availabilityRecoveryRequired: false,
|
||||
})
|
||||
expect(result.snapshot.registries["@acme"]).toMatchObject({
|
||||
status: "unavailable",
|
||||
monitoringLimited: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("refreshes hygiene after a reachable invalid index", async () => {
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState: createState(
|
||||
createEntryState({
|
||||
latestHygiene: {
|
||||
contentTypeJson: true,
|
||||
noDuplicateNames: true,
|
||||
nameMatches: true,
|
||||
},
|
||||
})
|
||||
),
|
||||
mode: "hourly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => ({
|
||||
...createSuccessfulFetch(),
|
||||
json: { invalid: true },
|
||||
contentType: "text/plain",
|
||||
})),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result.state.registries["@acme"].latestHygiene).toEqual({
|
||||
contentTypeJson: false,
|
||||
noDuplicateNames: null,
|
||||
nameMatches: null,
|
||||
})
|
||||
})
|
||||
|
||||
it("rotates enough daily items to cover a catalog in thirty days", async () => {
|
||||
const itemNames = Array.from({ length: 61 }, (_, index) => `item-${index}`)
|
||||
const fetchJson = vi.fn(async (url: string) => {
|
||||
const item = url.match(/item-\d+/)?.[0] ?? "item-0"
|
||||
return {
|
||||
...createSuccessfulFetch(),
|
||||
json: { name: item, type: "registry:ui", files: [] },
|
||||
finalUrl: url,
|
||||
}
|
||||
})
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState: createState(createEntryState({ itemNames })),
|
||||
mode: "daily",
|
||||
now: NOW,
|
||||
fetchJson,
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
expect(fetchJson).toHaveBeenCalledTimes(3)
|
||||
expect(result.state.registries["@acme"].itemCursor).toBe(3)
|
||||
expect(result.run.totals.itemChecks).toBe(3)
|
||||
expect(result.run.results["@acme"].items).toHaveLength(3)
|
||||
expect(result.state.lastDailyRunAt).toBe(NOW.toISOString())
|
||||
})
|
||||
|
||||
it("excludes item challenges from validation rates", async () => {
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState: createState(
|
||||
createEntryState({
|
||||
itemNames: ["button"],
|
||||
daily: [
|
||||
{
|
||||
date: "2026-08-24",
|
||||
availabilitySuccesses: 0,
|
||||
availabilityObservations: 0,
|
||||
challengeObservations: 0,
|
||||
schemaSuccesses: 0,
|
||||
schemaObservations: 0,
|
||||
itemSuccesses: 8,
|
||||
itemObservations: 9,
|
||||
dryRunSuccesses: 0,
|
||||
dryRunObservations: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
mode: "daily",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => ({
|
||||
ok: false as const,
|
||||
failureCode: "bot_challenge",
|
||||
reachable: false,
|
||||
botChallenge: true,
|
||||
status: 403,
|
||||
durationMs: 80,
|
||||
responseSize: 200,
|
||||
contentType: "text/html",
|
||||
redirectCount: 0,
|
||||
finalUrl: "https://acme.example.com/r/button.json",
|
||||
})),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
const bucket = result.state.registries["@acme"].daily[0]
|
||||
expect(bucket.itemObservations).toBe(9)
|
||||
expect(bucket.itemSuccesses).toBe(8)
|
||||
expect(result.run.totals).toMatchObject({
|
||||
challenges: 1,
|
||||
itemChecks: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it("runs one rotating weekly dry-run item", async () => {
|
||||
const runDryRun = vi.fn(async () => ({
|
||||
success: true,
|
||||
durationMs: 250,
|
||||
}))
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState: createState(
|
||||
createEntryState({ itemNames: ["button", "dialog"] })
|
||||
),
|
||||
mode: "weekly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(),
|
||||
runDryRun,
|
||||
})
|
||||
|
||||
expect(runDryRun).toHaveBeenCalledWith({
|
||||
namespace: "@acme",
|
||||
item: "button",
|
||||
registryUrl: DIRECTORY[0].url,
|
||||
})
|
||||
expect(result.run.totals.dryRuns).toBe(1)
|
||||
expect(result.state.registries["@acme"].recentDryRuns).toHaveLength(1)
|
||||
expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString())
|
||||
})
|
||||
|
||||
it("continues weekly rotation after dry-run history is trimmed", async () => {
|
||||
const runDryRun = vi.fn(async () => ({
|
||||
success: true,
|
||||
durationMs: 250,
|
||||
}))
|
||||
const recentDryRuns = Array.from({ length: 12 }, (_, index) => ({
|
||||
checkedAt: `2026-08-${String(index + 1).padStart(2, "0")}T12:00:00.000Z`,
|
||||
item: index === 11 ? "dialog" : "button",
|
||||
success: true,
|
||||
durationMs: 250,
|
||||
}))
|
||||
|
||||
await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState: createState(
|
||||
createEntryState({
|
||||
itemNames: ["button", "dialog", "tooltip"],
|
||||
recentDryRuns,
|
||||
})
|
||||
),
|
||||
mode: "weekly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(),
|
||||
runDryRun,
|
||||
})
|
||||
|
||||
expect(runDryRun).toHaveBeenCalledWith({
|
||||
namespace: "@acme",
|
||||
item: "tooltip",
|
||||
registryUrl: DIRECTORY[0].url,
|
||||
})
|
||||
})
|
||||
|
||||
it("runs delayed auto checks using their last run timestamps", async () => {
|
||||
const previousState = {
|
||||
...createState(createEntryState({ itemNames: ["button"] })),
|
||||
lastDailyRunAt: "2026-08-23T15:00:00.000Z",
|
||||
lastWeeklyRunAt: "2026-08-18T12:00:00.000Z",
|
||||
}
|
||||
const fetchJson = vi.fn(async (url: string) =>
|
||||
url.endsWith("/registry.json")
|
||||
? createSuccessfulFetch()
|
||||
: {
|
||||
...createSuccessfulFetch(),
|
||||
json: { name: "item-0", type: "registry:ui", files: [] },
|
||||
finalUrl: url,
|
||||
}
|
||||
)
|
||||
const runDryRun = vi.fn(async () => ({
|
||||
success: true,
|
||||
durationMs: 250,
|
||||
}))
|
||||
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState,
|
||||
mode: "auto",
|
||||
now: NOW,
|
||||
fetchJson,
|
||||
runDryRun,
|
||||
})
|
||||
|
||||
expect(result.run.totals.itemChecks).toBe(1)
|
||||
expect(result.run.totals.dryRuns).toBe(1)
|
||||
expect(result.state.lastDailyRunAt).toBe(NOW.toISOString())
|
||||
expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString())
|
||||
})
|
||||
|
||||
it("skips auto checks until their elapsed intervals", async () => {
|
||||
const previousState = {
|
||||
...createState(createEntryState({ itemNames: ["button"] })),
|
||||
lastDailyRunAt: "2026-08-23T17:00:00.000Z",
|
||||
lastWeeklyRunAt: "2026-08-19T12:00:00.000Z",
|
||||
}
|
||||
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState,
|
||||
mode: "auto",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
expect(result.run.totals.itemChecks).toBe(0)
|
||||
expect(result.run.totals.dryRuns).toBe(0)
|
||||
expect(result.state.lastDailyRunAt).toBe(previousState.lastDailyRunAt)
|
||||
expect(result.state.lastWeeklyRunAt).toBe(previousState.lastWeeklyRunAt)
|
||||
})
|
||||
|
||||
it("runs auto checks for state without last run timestamps", async () => {
|
||||
const previousState = createState(
|
||||
createEntryState({ itemNames: ["button"] })
|
||||
)
|
||||
const fetchJson = vi.fn(async (url: string) =>
|
||||
url.endsWith("/registry.json")
|
||||
? createSuccessfulFetch()
|
||||
: {
|
||||
...createSuccessfulFetch(),
|
||||
json: { name: "item-0", type: "registry:ui", files: [] },
|
||||
finalUrl: url,
|
||||
}
|
||||
)
|
||||
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState,
|
||||
mode: "auto",
|
||||
now: NOW,
|
||||
fetchJson,
|
||||
runDryRun: vi.fn(async () => ({
|
||||
success: true,
|
||||
durationMs: 250,
|
||||
})),
|
||||
})
|
||||
|
||||
expect(result.run.totals.itemChecks).toBe(1)
|
||||
expect(result.run.totals.dryRuns).toBe(1)
|
||||
expect(result.state.lastDailyRunAt).toBe(NOW.toISOString())
|
||||
expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString())
|
||||
})
|
||||
|
||||
it("runs auto checks when last run timestamps are in the future", async () => {
|
||||
const previousState = {
|
||||
...createState(createEntryState({ itemNames: ["button"] })),
|
||||
lastDailyRunAt: "2026-08-25T12:00:00.000Z",
|
||||
lastWeeklyRunAt: "2026-08-25T12:00:00.000Z",
|
||||
}
|
||||
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState,
|
||||
mode: "auto",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||
runDryRun: vi.fn(async () => ({
|
||||
success: true,
|
||||
durationMs: 250,
|
||||
})),
|
||||
})
|
||||
|
||||
expect(result.run.totals.itemChecks).toBe(1)
|
||||
expect(result.run.totals.dryRuns).toBe(1)
|
||||
expect(result.state.lastDailyRunAt).toBe(NOW.toISOString())
|
||||
expect(result.state.lastWeeklyRunAt).toBe(NOW.toISOString())
|
||||
})
|
||||
|
||||
it("limits concurrent CLI dry runs to four", async () => {
|
||||
const directory = Array.from({ length: 6 }, (_, index) => ({
|
||||
name: `@registry${index}`,
|
||||
homepage: `https://registry${index}.example.com`,
|
||||
url: `https://registry${index}.example.com/r/{name}.json`,
|
||||
description: `Registry ${index}`,
|
||||
logo: `https://registry${index}.example.com/logo.svg`,
|
||||
}))
|
||||
const previousState: RegistryMonitorState = {
|
||||
schemaVersion: 1,
|
||||
scoreVersion: 1,
|
||||
updatedAt: "2026-08-24T10:00:00.000Z",
|
||||
registries: Object.fromEntries(
|
||||
directory.map((entry) => [
|
||||
entry.name,
|
||||
createEntryState({ itemNames: ["button"] }),
|
||||
])
|
||||
),
|
||||
}
|
||||
let active = 0
|
||||
let maximumActive = 0
|
||||
const runDryRun = vi.fn(async () => {
|
||||
active += 1
|
||||
maximumActive = Math.max(maximumActive, active)
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
active -= 1
|
||||
return { success: true, durationMs: 20 }
|
||||
})
|
||||
|
||||
const result = await runRegistryMonitor({
|
||||
directory,
|
||||
previousState,
|
||||
mode: "weekly",
|
||||
now: NOW,
|
||||
fetchJson: vi.fn(),
|
||||
runDryRun,
|
||||
})
|
||||
|
||||
expect(result.run.totals.dryRuns).toBe(6)
|
||||
expect(maximumActive).toBe(4)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,566 @@
|
||||
import { registryItemSchema, registrySchema } from "shadcn/schema"
|
||||
|
||||
import {
|
||||
normalizeRegistryName,
|
||||
type RegistryDirectoryEntry,
|
||||
} from "../registry-directory"
|
||||
import { fetchRegistryJson, type RegistryJsonResult } from "./network"
|
||||
import {
|
||||
REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
REGISTRY_HEALTH_SCORE_VERSION,
|
||||
registryHealthSnapshotSchema,
|
||||
registryMonitorRunSchema,
|
||||
registryMonitorStateSchema,
|
||||
type RegistryDryRunObservation,
|
||||
type RegistryHealthDailyBucket,
|
||||
type RegistryHealthSnapshot,
|
||||
type RegistryIndexObservation,
|
||||
type RegistryItemObservation,
|
||||
type RegistryMonitorEntryState,
|
||||
type RegistryMonitorOutput,
|
||||
type RegistryMonitorRun,
|
||||
type RegistryMonitorState,
|
||||
} from "./schema"
|
||||
import { calculateGlobalMeans, calculateRegistryHealth } from "./score"
|
||||
import { createRegistryMonitorEntryState } from "./state"
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const RECENT_INDEX_RETENTION_MS = 8 * DAY_MS
|
||||
const DAILY_CHECK_INTERVAL_MS = 20 * 60 * 60 * 1000
|
||||
const WEEKLY_CHECK_INTERVAL_MS = 6 * DAY_MS
|
||||
const MAX_DRY_RUN_CONCURRENCY = 4
|
||||
|
||||
export type RegistryMonitorMode = RegistryMonitorRun["mode"]
|
||||
|
||||
export type RegistryDryRunResult = {
|
||||
success: boolean
|
||||
failureCode?: string
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
function createDailyBucket(date: string) {
|
||||
return {
|
||||
date,
|
||||
availabilitySuccesses: 0,
|
||||
availabilityObservations: 0,
|
||||
challengeObservations: 0,
|
||||
schemaSuccesses: 0,
|
||||
schemaObservations: 0,
|
||||
itemSuccesses: 0,
|
||||
itemObservations: 0,
|
||||
dryRunSuccesses: 0,
|
||||
dryRunObservations: 0,
|
||||
} satisfies RegistryHealthDailyBucket
|
||||
}
|
||||
|
||||
function getDailyBucket(state: RegistryMonitorEntryState, now: Date) {
|
||||
const date = now.toISOString().slice(0, 10)
|
||||
let bucket = state.daily.find((entry) => entry.date === date)
|
||||
|
||||
if (!bucket) {
|
||||
bucket = createDailyBucket(date)
|
||||
state.daily.push(bucket)
|
||||
}
|
||||
|
||||
return bucket
|
||||
}
|
||||
|
||||
function trimState(state: RegistryMonitorEntryState, now: Date) {
|
||||
state.recentIndex = state.recentIndex
|
||||
.filter(
|
||||
(observation) =>
|
||||
now.getTime() - new Date(observation.checkedAt).getTime() <
|
||||
RECENT_INDEX_RETENTION_MS
|
||||
)
|
||||
.slice(-256)
|
||||
state.recentDryRuns = state.recentDryRuns.slice(-12)
|
||||
state.daily = state.daily
|
||||
.filter(
|
||||
(bucket) =>
|
||||
now.getTime() - new Date(`${bucket.date}T00:00:00.000Z`).getTime() <
|
||||
31 * DAY_MS
|
||||
)
|
||||
.toSorted((a, b) => a.date.localeCompare(b.date))
|
||||
}
|
||||
|
||||
function isJsonMediaType(contentType: string) {
|
||||
const mediaType = contentType.split(";", 1)[0].trim().toLowerCase()
|
||||
return (
|
||||
mediaType === "application/json" ||
|
||||
mediaType === "text/json" ||
|
||||
mediaType.endsWith("+json")
|
||||
)
|
||||
}
|
||||
|
||||
function getIndexObservation(
|
||||
result: RegistryJsonResult,
|
||||
entry: RegistryDirectoryEntry,
|
||||
now: Date
|
||||
) {
|
||||
if (!result.ok) {
|
||||
return {
|
||||
observation: {
|
||||
checkedAt: now.toISOString(),
|
||||
outcome: result.botChallenge
|
||||
? ("bot_challenge" as const)
|
||||
: result.reachable
|
||||
? ("reachable" as const)
|
||||
: ("unreachable" as const),
|
||||
status: result.status,
|
||||
failureCode: result.failureCode,
|
||||
durationMs: result.durationMs,
|
||||
responseSize: result.responseSize,
|
||||
redirectCount: result.redirectCount,
|
||||
schemaValid: result.reachable ? false : undefined,
|
||||
contentTypeJson: result.contentType
|
||||
? isJsonMediaType(result.contentType)
|
||||
: undefined,
|
||||
} satisfies RegistryIndexObservation,
|
||||
itemNames: null,
|
||||
}
|
||||
}
|
||||
|
||||
const parsed = registrySchema.safeParse(result.json)
|
||||
const itemNames = parsed.success
|
||||
? parsed.data.items.map((item) => item.name)
|
||||
: null
|
||||
const duplicateNames = itemNames
|
||||
? new Set(itemNames).size !== itemNames.length
|
||||
: undefined
|
||||
const nameMatches = parsed.success
|
||||
? normalizeRegistryName(parsed.data.name) ===
|
||||
normalizeRegistryName(entry.name)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
observation: {
|
||||
checkedAt: now.toISOString(),
|
||||
outcome: "reachable" as const,
|
||||
status: result.status,
|
||||
failureCode: parsed.success ? undefined : "invalid_schema",
|
||||
durationMs: result.durationMs,
|
||||
responseSize: result.responseSize,
|
||||
redirectCount: result.redirectCount,
|
||||
schemaValid: parsed.success,
|
||||
contentTypeJson: isJsonMediaType(result.contentType),
|
||||
duplicateNames,
|
||||
nameMatches,
|
||||
itemCount: itemNames?.length,
|
||||
} satisfies RegistryIndexObservation,
|
||||
itemNames,
|
||||
}
|
||||
}
|
||||
|
||||
function recordIndexObservation(
|
||||
state: RegistryMonitorEntryState,
|
||||
observation: RegistryIndexObservation,
|
||||
itemNames: string[] | null,
|
||||
now: Date
|
||||
) {
|
||||
state.recentIndex.push(observation)
|
||||
const bucket = getDailyBucket(state, now)
|
||||
|
||||
if (observation.outcome !== "bot_challenge") {
|
||||
if (observation.outcome === "unreachable") {
|
||||
state.consecutiveSuccessfulIndexes = 0
|
||||
state.consecutiveIndexFailures += 1
|
||||
state.availabilityOutageSince ??=
|
||||
state.lastSuccessfulCheck ?? observation.checkedAt
|
||||
const outageDuration =
|
||||
new Date(observation.checkedAt).getTime() -
|
||||
new Date(state.availabilityOutageSince).getTime()
|
||||
const outageRequiresRecovery = outageDuration >= DAY_MS
|
||||
if (state.consecutiveIndexFailures >= 3 || outageRequiresRecovery) {
|
||||
state.availabilityRecoveryRequired = true
|
||||
}
|
||||
} else {
|
||||
const outageDuration = state.availabilityOutageSince
|
||||
? new Date(observation.checkedAt).getTime() -
|
||||
new Date(state.availabilityOutageSince).getTime()
|
||||
: 0
|
||||
const outageRequiresRecovery = outageDuration >= DAY_MS
|
||||
state.consecutiveIndexFailures = 0
|
||||
|
||||
if (observation.schemaValid) {
|
||||
state.consecutiveSuccessfulIndexes += 1
|
||||
if (
|
||||
(state.availabilityRecoveryRequired || outageRequiresRecovery) &&
|
||||
state.consecutiveSuccessfulIndexes < 2
|
||||
) {
|
||||
state.availabilityRecoveryRequired = true
|
||||
} else {
|
||||
state.availabilityRecoveryRequired = false
|
||||
delete state.availabilityOutageSince
|
||||
}
|
||||
} else {
|
||||
state.consecutiveSuccessfulIndexes = 0
|
||||
if (state.availabilityRecoveryRequired || outageRequiresRecovery) {
|
||||
state.availabilityRecoveryRequired = true
|
||||
} else {
|
||||
delete state.availabilityOutageSince
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (observation.outcome === "bot_challenge") {
|
||||
bucket.challengeObservations += 1
|
||||
} else {
|
||||
bucket.availabilityObservations += 1
|
||||
bucket.availabilitySuccesses += observation.outcome === "reachable" ? 1 : 0
|
||||
}
|
||||
|
||||
if (observation.outcome === "reachable") {
|
||||
bucket.schemaObservations += 1
|
||||
bucket.schemaSuccesses += observation.schemaValid ? 1 : 0
|
||||
}
|
||||
|
||||
if (observation.outcome === "reachable") {
|
||||
state.latestHygiene = {
|
||||
contentTypeJson: observation.contentTypeJson ?? false,
|
||||
noDuplicateNames:
|
||||
observation.duplicateNames === undefined
|
||||
? null
|
||||
: !observation.duplicateNames,
|
||||
nameMatches: observation.nameMatches ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
if (observation.schemaValid && itemNames) {
|
||||
state.itemNames = itemNames
|
||||
state.lastSuccessfulCheck = observation.checkedAt
|
||||
}
|
||||
}
|
||||
|
||||
function getItemNamesForDailyCheck(state: RegistryMonitorEntryState) {
|
||||
if (state.itemNames.length === 0) return []
|
||||
|
||||
const count = Math.max(1, Math.ceil(state.itemNames.length / 30))
|
||||
const selected = Array.from({ length: count }, (_, offset) => {
|
||||
const index = (state.itemCursor + offset) % state.itemNames.length
|
||||
return state.itemNames[index]
|
||||
})
|
||||
state.itemCursor = (state.itemCursor + count) % state.itemNames.length
|
||||
return selected
|
||||
}
|
||||
|
||||
function recordItemObservation(
|
||||
state: RegistryMonitorEntryState,
|
||||
observation: RegistryItemObservation,
|
||||
now: Date
|
||||
) {
|
||||
if (observation.failureCode === "bot_challenge") return
|
||||
|
||||
const bucket = getDailyBucket(state, now)
|
||||
bucket.itemObservations += 1
|
||||
bucket.itemSuccesses += observation.success ? 1 : 0
|
||||
}
|
||||
|
||||
function getNextDryRunItem(state: RegistryMonitorEntryState) {
|
||||
if (state.itemNames.length === 0) return null
|
||||
|
||||
const previousItem = state.recentDryRuns.at(-1)?.item
|
||||
const previousIndex = previousItem
|
||||
? state.itemNames.indexOf(previousItem)
|
||||
: -1
|
||||
|
||||
return state.itemNames[(previousIndex + 1) % state.itemNames.length]
|
||||
}
|
||||
|
||||
function recordDryRunObservation(
|
||||
state: RegistryMonitorEntryState,
|
||||
observation: RegistryDryRunObservation,
|
||||
now: Date
|
||||
) {
|
||||
state.recentDryRuns.push(observation)
|
||||
const bucket = getDailyBucket(state, now)
|
||||
bucket.dryRunObservations += 1
|
||||
bucket.dryRunSuccesses += observation.success ? 1 : 0
|
||||
}
|
||||
|
||||
function replaceRegistryItem(url: string, item: string) {
|
||||
return url.replace("{name}", item)
|
||||
}
|
||||
|
||||
function hasIntervalElapsed(
|
||||
lastRunAt: string | undefined,
|
||||
now: Date,
|
||||
intervalMs: number
|
||||
) {
|
||||
if (!lastRunAt) return true
|
||||
|
||||
const elapsed = now.getTime() - new Date(lastRunAt).getTime()
|
||||
return elapsed < 0 || elapsed >= intervalMs
|
||||
}
|
||||
|
||||
function getChecks(
|
||||
mode: RegistryMonitorMode,
|
||||
now: Date,
|
||||
previousState?: RegistryMonitorState | null
|
||||
) {
|
||||
if (mode === "all") {
|
||||
return { index: true, items: true, dryRun: true }
|
||||
}
|
||||
|
||||
if (mode !== "auto") {
|
||||
return {
|
||||
index: mode === "hourly",
|
||||
items: mode === "daily",
|
||||
dryRun: mode === "weekly",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
index: true,
|
||||
items: hasIntervalElapsed(
|
||||
previousState?.lastDailyRunAt,
|
||||
now,
|
||||
DAILY_CHECK_INTERVAL_MS
|
||||
),
|
||||
dryRun: hasIntervalElapsed(
|
||||
previousState?.lastWeeklyRunAt,
|
||||
now,
|
||||
WEEKLY_CHECK_INTERVAL_MS
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function createConcurrencyLimit(concurrency: number) {
|
||||
let active = 0
|
||||
const waiting: Array<() => void> = []
|
||||
|
||||
return async function limit<T>(task: () => Promise<T>) {
|
||||
while (active >= concurrency) {
|
||||
await new Promise<void>((resolve) => waiting.push(resolve))
|
||||
}
|
||||
|
||||
active += 1
|
||||
try {
|
||||
return await task()
|
||||
} finally {
|
||||
active -= 1
|
||||
waiting.shift()?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T>(
|
||||
values: T[],
|
||||
concurrency: number,
|
||||
task: (value: T) => Promise<void>
|
||||
) {
|
||||
let cursor = 0
|
||||
|
||||
async function worker() {
|
||||
while (cursor < values.length) {
|
||||
const index = cursor
|
||||
cursor += 1
|
||||
await task(values[index])
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(concurrency, values.length) }, () => worker())
|
||||
)
|
||||
}
|
||||
|
||||
function groupByHost(directory: RegistryDirectoryEntry[]) {
|
||||
const groups = new Map<string, RegistryDirectoryEntry[]>()
|
||||
|
||||
for (const entry of directory) {
|
||||
let host = entry.name
|
||||
try {
|
||||
host = new URL(entry.url).host
|
||||
} catch {
|
||||
host = entry.name
|
||||
}
|
||||
|
||||
const group = groups.get(host) ?? []
|
||||
group.push(entry)
|
||||
groups.set(host, group)
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
}
|
||||
|
||||
export async function runRegistryMonitor({
|
||||
directory,
|
||||
previousState,
|
||||
mode = "auto",
|
||||
now = new Date(),
|
||||
fetchJson = fetchRegistryJson,
|
||||
runDryRun,
|
||||
concurrency = 12,
|
||||
}: {
|
||||
directory: RegistryDirectoryEntry[]
|
||||
previousState?: RegistryMonitorState | null
|
||||
mode?: RegistryMonitorMode
|
||||
now?: Date
|
||||
fetchJson?: typeof fetchRegistryJson
|
||||
runDryRun: (options: {
|
||||
namespace: string
|
||||
item: string
|
||||
registryUrl: string
|
||||
}) => Promise<RegistryDryRunResult>
|
||||
concurrency?: number
|
||||
}): Promise<RegistryMonitorOutput> {
|
||||
const startedAt = now.toISOString()
|
||||
const checks = getChecks(mode, now, previousState)
|
||||
const state: RegistryMonitorState = {
|
||||
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
scoreVersion: REGISTRY_HEALTH_SCORE_VERSION,
|
||||
updatedAt: now.toISOString(),
|
||||
...(previousState?.lastDailyRunAt
|
||||
? { lastDailyRunAt: previousState.lastDailyRunAt }
|
||||
: {}),
|
||||
...(previousState?.lastWeeklyRunAt
|
||||
? { lastWeeklyRunAt: previousState.lastWeeklyRunAt }
|
||||
: {}),
|
||||
registries: {},
|
||||
}
|
||||
const limitDryRun = createConcurrencyLimit(MAX_DRY_RUN_CONCURRENCY)
|
||||
const runResults: RegistryMonitorRun["results"] = {}
|
||||
const totals = {
|
||||
registries: directory.length,
|
||||
reachable: 0,
|
||||
unavailable: 0,
|
||||
challenges: 0,
|
||||
itemChecks: 0,
|
||||
dryRuns: 0,
|
||||
}
|
||||
|
||||
for (const entry of directory) {
|
||||
state.registries[entry.name] = structuredClone(
|
||||
previousState?.registries[entry.name] ??
|
||||
createRegistryMonitorEntryState(now)
|
||||
)
|
||||
runResults[entry.name] = { items: [] }
|
||||
}
|
||||
|
||||
await mapWithConcurrency(
|
||||
groupByHost(directory),
|
||||
concurrency,
|
||||
async (group) => {
|
||||
for (const entry of group) {
|
||||
const entryState = state.registries[entry.name]
|
||||
const result = runResults[entry.name]
|
||||
|
||||
if (checks.index) {
|
||||
const indexResult = await fetchJson(
|
||||
replaceRegistryItem(entry.url, "registry")
|
||||
)
|
||||
const { observation, itemNames } = getIndexObservation(
|
||||
indexResult,
|
||||
entry,
|
||||
now
|
||||
)
|
||||
recordIndexObservation(entryState, observation, itemNames, now)
|
||||
result.index = observation
|
||||
|
||||
if (observation.outcome === "reachable") totals.reachable += 1
|
||||
if (observation.outcome === "unreachable") totals.unavailable += 1
|
||||
if (observation.outcome === "bot_challenge") totals.challenges += 1
|
||||
}
|
||||
|
||||
if (checks.items) {
|
||||
for (const item of getItemNamesForDailyCheck(entryState)) {
|
||||
const itemResult = await fetchJson(
|
||||
replaceRegistryItem(entry.url, item)
|
||||
)
|
||||
const parsed = itemResult.ok
|
||||
? registryItemSchema.safeParse(itemResult.json)
|
||||
: null
|
||||
const success = !!(parsed?.success && parsed.data.name === item)
|
||||
const observation: RegistryItemObservation = {
|
||||
checkedAt: now.toISOString(),
|
||||
item,
|
||||
success,
|
||||
failureCode: success
|
||||
? undefined
|
||||
: itemResult.ok
|
||||
? parsed?.success
|
||||
? "name_mismatch"
|
||||
: "invalid_schema"
|
||||
: itemResult.failureCode,
|
||||
durationMs: itemResult.durationMs,
|
||||
}
|
||||
recordItemObservation(entryState, observation, now)
|
||||
result.items.push(observation)
|
||||
totals.itemChecks += 1
|
||||
if (!itemResult.ok && itemResult.botChallenge) {
|
||||
totals.challenges += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dryRunItem = checks.dryRun ? getNextDryRunItem(entryState) : null
|
||||
if (dryRunItem) {
|
||||
const dryRunResult = await limitDryRun(() =>
|
||||
runDryRun({
|
||||
namespace: entry.name,
|
||||
item: dryRunItem,
|
||||
registryUrl: entry.url,
|
||||
})
|
||||
)
|
||||
const observation: RegistryDryRunObservation = {
|
||||
checkedAt: now.toISOString(),
|
||||
item: dryRunItem,
|
||||
...dryRunResult,
|
||||
}
|
||||
recordDryRunObservation(entryState, observation, now)
|
||||
result.dryRun = observation
|
||||
totals.dryRuns += 1
|
||||
}
|
||||
|
||||
trimState(entryState, now)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (checks.items) {
|
||||
state.lastDailyRunAt = now.toISOString()
|
||||
}
|
||||
if (checks.dryRun) {
|
||||
state.lastWeeklyRunAt = now.toISOString()
|
||||
}
|
||||
|
||||
const registryUrls = Object.fromEntries(
|
||||
directory.map((entry) => [entry.name, entry.url])
|
||||
)
|
||||
const globalMeans = calculateGlobalMeans(state, registryUrls, now)
|
||||
const snapshot: RegistryHealthSnapshot = {
|
||||
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
scoreVersion: REGISTRY_HEALTH_SCORE_VERSION,
|
||||
generatedAt: now.toISOString(),
|
||||
globalMeans,
|
||||
registries: {},
|
||||
}
|
||||
|
||||
for (const entry of directory) {
|
||||
const entryState = state.registries[entry.name]
|
||||
const health = calculateRegistryHealth({
|
||||
state: entryState,
|
||||
registryUrl: entry.url,
|
||||
globalMeans,
|
||||
now,
|
||||
})
|
||||
entryState.status = health.status
|
||||
snapshot.registries[entry.name] = health
|
||||
}
|
||||
|
||||
const run: RegistryMonitorRun = {
|
||||
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
startedAt,
|
||||
completedAt: new Date().toISOString(),
|
||||
mode,
|
||||
totals,
|
||||
results: runResults,
|
||||
diagnostics: [],
|
||||
}
|
||||
|
||||
return {
|
||||
state: registryMonitorStateSchema.parse(state),
|
||||
snapshot: registryHealthSnapshotSchema.parse(snapshot),
|
||||
run: registryMonitorRunSchema.parse(run),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
createGuardedConnector,
|
||||
detectBotChallenge,
|
||||
fetchRegistryJson,
|
||||
isForbiddenAddress,
|
||||
validateRegistryUrl,
|
||||
} from "./network"
|
||||
|
||||
describe("network destination safety", () => {
|
||||
it.each([
|
||||
"127.0.0.1",
|
||||
"10.0.0.1",
|
||||
"168.63.129.16",
|
||||
"169.254.169.254",
|
||||
"192.0.2.1",
|
||||
"192.88.99.1",
|
||||
"192.168.1.1",
|
||||
"::1",
|
||||
"64:ff9b::7f00:1",
|
||||
"100::",
|
||||
"2001::1",
|
||||
"2002:7f00:1::",
|
||||
"fe80::1",
|
||||
"fec0::1",
|
||||
"fd00:ec2::254",
|
||||
"::ffff:127.0.0.1",
|
||||
])("rejects non-public address %s", (address) => {
|
||||
expect(isForbiddenAddress(address)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"])(
|
||||
"allows public address %s",
|
||||
(address) => {
|
||||
expect(isForbiddenAddress(address)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it("rejects unsupported protocols and embedded credentials", () => {
|
||||
expect(() => validateRegistryUrl("file:///etc/passwd")).toThrow(
|
||||
"unsupported_protocol"
|
||||
)
|
||||
expect(() =>
|
||||
validateRegistryUrl("https://user:secret@example.com/registry.json")
|
||||
).toThrow("embedded_credentials")
|
||||
})
|
||||
|
||||
it("pins the validated address used by the socket connector", async () => {
|
||||
const connect = vi.fn((_options, callback) => callback(null, {}))
|
||||
const connector = createGuardedConnector({
|
||||
resolve: async () => [{ address: "203.0.114.10", family: 4 }],
|
||||
connect,
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
connector(
|
||||
{
|
||||
hostname: "registry.example.com",
|
||||
protocol: "https:",
|
||||
port: "443",
|
||||
},
|
||||
(error, _socket) => (error ? reject(error) : resolve())
|
||||
)
|
||||
})
|
||||
|
||||
expect(connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: "203.0.114.10",
|
||||
servername: "registry.example.com",
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects a hostname when DNS includes a private address", async () => {
|
||||
const connector = createGuardedConnector({
|
||||
resolve: async () => [
|
||||
{ address: "203.0.114.10", family: 4 },
|
||||
{ address: "127.0.0.1", family: 4 },
|
||||
],
|
||||
connect: vi.fn(),
|
||||
})
|
||||
|
||||
await expect(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
connector(
|
||||
{
|
||||
hostname: "registry.example.com",
|
||||
protocol: "https:",
|
||||
port: "443",
|
||||
},
|
||||
(error, _socket) => (error ? reject(error) : resolve())
|
||||
)
|
||||
})
|
||||
).rejects.toThrow("forbidden_address")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchRegistryJson", () => {
|
||||
const dispatcher = {
|
||||
close: vi.fn(async () => {}),
|
||||
}
|
||||
|
||||
it("validates every manual redirect target", async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://127.0.0.1/registry.json" },
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchRegistryJson("https://example.com/registry.json", {
|
||||
fetchImpl,
|
||||
createDispatcher: () => dispatcher as never,
|
||||
attempts: 1,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
failureCode: "forbidden_address",
|
||||
})
|
||||
})
|
||||
|
||||
it("shares one timeout budget across redirects", async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2026-08-24T12:00:00.000Z"))
|
||||
|
||||
try {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
vi.setSystemTime(new Date("2026-08-24T12:00:00.080Z"))
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://example.com/final.json" },
|
||||
})
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
async (_url: string, init: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const resultPromise = fetchRegistryJson(
|
||||
"https://example.com/registry.json",
|
||||
{
|
||||
fetchImpl,
|
||||
createDispatcher: () => dispatcher as never,
|
||||
attempts: 1,
|
||||
timeoutMs: 100,
|
||||
}
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
ok: false,
|
||||
failureCode: "timeout",
|
||||
})
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it("retries a retryable response and honors Retry-After", async () => {
|
||||
const wait = vi.fn(async () => {})
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("busy", {
|
||||
status: 429,
|
||||
headers: { "retry-after": "1" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('{"name":"acme"}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchRegistryJson(
|
||||
"https://example.com/registry.json",
|
||||
{
|
||||
fetchImpl,
|
||||
createDispatcher: () => dispatcher as never,
|
||||
wait,
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(wait).toHaveBeenCalledWith(1000)
|
||||
})
|
||||
|
||||
it("enforces the response-size limit", async () => {
|
||||
const result = await fetchRegistryJson(
|
||||
"https://example.com/registry.json",
|
||||
{
|
||||
fetchImpl: async () => new Response("too large"),
|
||||
createDispatcher: () => dispatcher as never,
|
||||
maximumBytes: 3,
|
||||
attempts: 1,
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
failureCode: "body_too_large",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectBotChallenge", () => {
|
||||
it("recognizes a provider challenge page", () => {
|
||||
expect(
|
||||
detectBotChallenge({
|
||||
status: 403,
|
||||
contentType: "text/html",
|
||||
server: "cloudflare",
|
||||
body: "<title>Just a moment...</title><div class='cf-chl-test'>",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not classify an ordinary JSON 403 as a challenge", () => {
|
||||
expect(
|
||||
detectBotChallenge({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
server: "",
|
||||
body: '{"error":"forbidden"}',
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,586 @@
|
||||
import { lookup } from "node:dns/promises"
|
||||
import { isIP } from "node:net"
|
||||
import {
|
||||
Agent,
|
||||
buildConnector,
|
||||
fetch as undiciFetch,
|
||||
type Dispatcher,
|
||||
} from "undici"
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
|
||||
const DEFAULT_MAX_REDIRECTS = 5
|
||||
const MAX_RETRY_AFTER_MS = 30_000
|
||||
|
||||
type ResolvedAddress = {
|
||||
address: string
|
||||
family: number
|
||||
}
|
||||
|
||||
type ResolveHostname = (hostname: string) => Promise<ResolvedAddress[]>
|
||||
type Connector = ReturnType<typeof buildConnector>
|
||||
|
||||
type FetchLike = (
|
||||
url: string,
|
||||
init: RequestInit & { dispatcher?: Dispatcher }
|
||||
) => Promise<Response>
|
||||
|
||||
export type RegistryJsonResult =
|
||||
| {
|
||||
ok: true
|
||||
json: unknown
|
||||
status: number
|
||||
durationMs: number
|
||||
responseSize: number
|
||||
contentType: string
|
||||
redirectCount: number
|
||||
finalUrl: string
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
failureCode: string
|
||||
reachable: boolean
|
||||
botChallenge: boolean
|
||||
status?: number
|
||||
durationMs: number
|
||||
responseSize?: number
|
||||
contentType?: string
|
||||
redirectCount: number
|
||||
finalUrl: string
|
||||
}
|
||||
|
||||
type RegistryJsonAttemptResult =
|
||||
| Extract<RegistryJsonResult, { ok: true }>
|
||||
| (Extract<RegistryJsonResult, { ok: false }> & {
|
||||
retryAfterMs?: number
|
||||
})
|
||||
|
||||
function parseIpv4(address: string) {
|
||||
if (isIP(address) !== 4) return null
|
||||
|
||||
return address.split(".").map(Number)
|
||||
}
|
||||
|
||||
function parseIpv6(address: string) {
|
||||
if (address.includes("%") || isIP(address) !== 6) return null
|
||||
|
||||
let value = address.toLowerCase()
|
||||
const ipv4Match = value.match(/(\d+\.\d+\.\d+\.\d+)$/)
|
||||
|
||||
if (ipv4Match) {
|
||||
const ipv4 = parseIpv4(ipv4Match[1])
|
||||
if (!ipv4) return null
|
||||
value = value.replace(
|
||||
ipv4Match[1],
|
||||
`${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${(
|
||||
(ipv4[2] << 8) |
|
||||
ipv4[3]
|
||||
).toString(16)}`
|
||||
)
|
||||
}
|
||||
|
||||
const [left = "", right = ""] = value.split("::")
|
||||
const leftParts = left ? left.split(":") : []
|
||||
const rightParts = right ? right.split(":") : []
|
||||
const missing = 8 - leftParts.length - rightParts.length
|
||||
const parts = value.includes("::")
|
||||
? [
|
||||
...leftParts,
|
||||
...Array.from({ length: missing }, () => "0"),
|
||||
...rightParts,
|
||||
]
|
||||
: leftParts
|
||||
|
||||
if (parts.length !== 8) return null
|
||||
|
||||
const parsed = parts.map((part) => Number.parseInt(part || "0", 16))
|
||||
return parsed.every((part) => Number.isInteger(part) && part <= 0xffff)
|
||||
? parsed
|
||||
: null
|
||||
}
|
||||
|
||||
function isForbiddenIpv4(address: string) {
|
||||
const parts = parseIpv4(address)
|
||||
if (!parts) return true
|
||||
|
||||
const [a, b, c] = parts
|
||||
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 0 && c === 0) ||
|
||||
(a === 192 && b === 0 && c === 2) ||
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
(a === 198 && b === 51 && c === 100) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
(a === 168 && b === 63 && c === 129 && parts[3] === 16) ||
|
||||
a >= 224
|
||||
)
|
||||
}
|
||||
|
||||
function isForbiddenIpv6(address: string) {
|
||||
const parts = parseIpv6(address)
|
||||
if (!parts) return true
|
||||
|
||||
const isUnspecified = parts.every((part) => part === 0)
|
||||
const isLoopback =
|
||||
parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1
|
||||
const isUniqueLocal = (parts[0] & 0xfe00) === 0xfc00
|
||||
const isLinkLocal = (parts[0] & 0xffc0) === 0xfe80
|
||||
const isSiteLocal = (parts[0] & 0xffc0) === 0xfec0
|
||||
const isMulticast = (parts[0] & 0xff00) === 0xff00
|
||||
const isDocumentation = parts[0] === 0x2001 && parts[1] === 0x0db8
|
||||
const isProtocolAssignment =
|
||||
parts[0] === 0x2001 && parts[1] >= 0 && parts[1] <= 0x01ff
|
||||
const isSixToFour = parts[0] === 0x2002
|
||||
const isDiscardOnly =
|
||||
parts[0] === 0x0100 && parts.slice(1, 4).every((part) => part === 0)
|
||||
const isNat64 =
|
||||
(parts[0] === 0x0064 &&
|
||||
parts[1] === 0xff9b &&
|
||||
parts.slice(2, 6).every((part) => part === 0)) ||
|
||||
(parts[0] === 0x0064 && parts[1] === 0xff9b && parts[2] === 0x0001)
|
||||
const isIpv4Mapped =
|
||||
parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff
|
||||
const isIpv4Compatible = parts.slice(0, 6).every((part) => part === 0)
|
||||
|
||||
if (isIpv4Mapped) {
|
||||
return isForbiddenIpv4(
|
||||
`${parts[6] >> 8}.${parts[6] & 255}.${parts[7] >> 8}.${parts[7] & 255}`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
isUnspecified ||
|
||||
isLoopback ||
|
||||
isUniqueLocal ||
|
||||
isLinkLocal ||
|
||||
isSiteLocal ||
|
||||
isMulticast ||
|
||||
isDocumentation ||
|
||||
isProtocolAssignment ||
|
||||
isSixToFour ||
|
||||
isDiscardOnly ||
|
||||
isNat64 ||
|
||||
isIpv4Compatible
|
||||
)
|
||||
}
|
||||
|
||||
export function isForbiddenAddress(address: string) {
|
||||
const normalized = address.replace(/^\[|\]$/g, "")
|
||||
const family = isIP(normalized)
|
||||
|
||||
if (family === 4) return isForbiddenIpv4(normalized)
|
||||
if (family === 6) return isForbiddenIpv6(normalized)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function validateRegistryUrl(input: string) {
|
||||
const url = new URL(input)
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("unsupported_protocol")
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
throw new Error("embedded_credentials")
|
||||
}
|
||||
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "")
|
||||
if (isIP(hostname) && isForbiddenAddress(hostname)) {
|
||||
throw new Error("forbidden_address")
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
const defaultResolve: ResolveHostname = (hostname) =>
|
||||
lookup(hostname, { all: true, verbatim: true })
|
||||
|
||||
export function createGuardedConnector({
|
||||
resolve = defaultResolve,
|
||||
connect = buildConnector({ timeout: DEFAULT_TIMEOUT_MS }),
|
||||
}: {
|
||||
resolve?: ResolveHostname
|
||||
connect?: Connector
|
||||
} = {}): Connector {
|
||||
return (options, callback) => {
|
||||
const hostname = options.hostname.replace(/^\[|\]$/g, "")
|
||||
|
||||
void (async () => {
|
||||
const addresses = isIP(hostname)
|
||||
? [{ address: hostname, family: isIP(hostname) }]
|
||||
: await resolve(hostname)
|
||||
|
||||
if (
|
||||
addresses.length === 0 ||
|
||||
addresses.some(({ address }) => isForbiddenAddress(address))
|
||||
) {
|
||||
throw new Error("forbidden_address")
|
||||
}
|
||||
|
||||
const [{ address }] = addresses
|
||||
connect(
|
||||
{
|
||||
...options,
|
||||
hostname: address,
|
||||
servername: options.servername ?? hostname,
|
||||
},
|
||||
callback
|
||||
)
|
||||
})().catch((error) =>
|
||||
callback(error instanceof Error ? error : new Error("dns_failure"), null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createGuardedDispatcher() {
|
||||
return new Agent({
|
||||
connect: createGuardedConnector(),
|
||||
headersTimeout: DEFAULT_TIMEOUT_MS,
|
||||
bodyTimeout: DEFAULT_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
async function readLimitedBody(response: Response, maximumBytes: number) {
|
||||
if (!response.body) return { text: "", size: 0 }
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
|
||||
if (size > maximumBytes) {
|
||||
await reader.cancel()
|
||||
throw new Error("body_too_large")
|
||||
}
|
||||
|
||||
chunks.push(value)
|
||||
}
|
||||
|
||||
const body = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
|
||||
return { text: new TextDecoder().decode(body), size }
|
||||
}
|
||||
|
||||
export function detectBotChallenge({
|
||||
status,
|
||||
contentType,
|
||||
server,
|
||||
body,
|
||||
}: {
|
||||
status: number
|
||||
contentType: string
|
||||
server: string
|
||||
body: string
|
||||
}) {
|
||||
const content = body.toLowerCase()
|
||||
const provider = server.toLowerCase()
|
||||
const isHtml = contentType.toLowerCase().includes("text/html")
|
||||
const marker = [
|
||||
"cf-chl-",
|
||||
"challenge-platform",
|
||||
"just a moment",
|
||||
"attention required",
|
||||
"vercel security checkpoint",
|
||||
"captcha",
|
||||
].some((value) => content.includes(value))
|
||||
|
||||
return (
|
||||
marker &&
|
||||
(isHtml || status === 403 || status === 429) &&
|
||||
(provider.includes("cloudflare") ||
|
||||
provider.includes("vercel") ||
|
||||
content.includes("challenge") ||
|
||||
content.includes("captcha"))
|
||||
)
|
||||
}
|
||||
|
||||
function isRedirect(status: number) {
|
||||
return [301, 302, 303, 307, 308].includes(status)
|
||||
}
|
||||
|
||||
function isRetryableStatus(status: number) {
|
||||
return status === 408 || status === 429 || status >= 500
|
||||
}
|
||||
|
||||
function parseRetryAfter(value: string | null, now: number) {
|
||||
if (!value) return null
|
||||
|
||||
const seconds = Number(value)
|
||||
const delay = Number.isFinite(seconds)
|
||||
? seconds * 1000
|
||||
: new Date(value).getTime() - now
|
||||
|
||||
if (!Number.isFinite(delay) || delay < 0) return null
|
||||
return Math.min(delay, MAX_RETRY_AFTER_MS)
|
||||
}
|
||||
|
||||
function sleep(milliseconds: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
async function fetchOnce(
|
||||
input: string,
|
||||
{
|
||||
timeoutMs,
|
||||
maximumBytes,
|
||||
maximumRedirects,
|
||||
fetchImpl,
|
||||
createDispatcher,
|
||||
}: {
|
||||
timeoutMs: number
|
||||
maximumBytes: number
|
||||
maximumRedirects: number
|
||||
fetchImpl: FetchLike
|
||||
createDispatcher: () => Dispatcher
|
||||
}
|
||||
) {
|
||||
const startedAt = Date.now()
|
||||
let currentUrl = validateRegistryUrl(input)
|
||||
let redirectCount = 0
|
||||
|
||||
while (true) {
|
||||
validateRegistryUrl(currentUrl.toString())
|
||||
const remainingMs = timeoutMs - (Date.now() - startedAt)
|
||||
if (remainingMs <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "timeout",
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
const dispatcher = createDispatcher()
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), remainingMs)
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(currentUrl.toString(), {
|
||||
dispatcher,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "shadcn-registry-health/1.0",
|
||||
},
|
||||
})
|
||||
|
||||
if (isRedirect(response.status)) {
|
||||
const location = response.headers.get("location")
|
||||
await response.body?.cancel()
|
||||
if (!location) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "redirect_without_location",
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
redirectCount += 1
|
||||
if (redirectCount > maximumRedirects) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "too_many_redirects",
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
currentUrl = validateRegistryUrl(
|
||||
new URL(location, currentUrl).toString()
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
let body: Awaited<ReturnType<typeof readLimitedBody>>
|
||||
try {
|
||||
body = await readLimitedBody(response, maximumBytes)
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "body_too_large",
|
||||
reachable: response.ok,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
const botChallenge = detectBotChallenge({
|
||||
status: response.status,
|
||||
contentType,
|
||||
server: response.headers.get("server") ?? "",
|
||||
body: body.text,
|
||||
})
|
||||
|
||||
if (botChallenge) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "bot_challenge",
|
||||
reachable: false,
|
||||
botChallenge: true,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: body.size,
|
||||
contentType,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: `http_${response.status}`,
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: body.size,
|
||||
contentType,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
retryAfterMs:
|
||||
parseRetryAfter(response.headers.get("retry-after"), Date.now()) ??
|
||||
undefined,
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
json: JSON.parse(body.text),
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: body.size,
|
||||
contentType,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "invalid_json",
|
||||
reachable: true,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: body.size,
|
||||
contentType,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
} catch (error) {
|
||||
const messages = [
|
||||
error instanceof Error ? error.message : "",
|
||||
error instanceof Error && error.cause instanceof Error
|
||||
? error.cause.message
|
||||
: "",
|
||||
]
|
||||
const guardedFailure = [
|
||||
"embedded_credentials",
|
||||
"forbidden_address",
|
||||
"unsupported_protocol",
|
||||
].find((failureCode) => messages.includes(failureCode))
|
||||
const failureCode =
|
||||
guardedFailure ??
|
||||
(error instanceof Error && error.name === "AbortError"
|
||||
? "timeout"
|
||||
: "transport_error")
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
failureCode,
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await dispatcher.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRegistryJson(
|
||||
input: string,
|
||||
options: {
|
||||
attempts?: number
|
||||
timeoutMs?: number
|
||||
maximumBytes?: number
|
||||
maximumRedirects?: number
|
||||
fetchImpl?: FetchLike
|
||||
createDispatcher?: () => Dispatcher
|
||||
wait?: (milliseconds: number) => Promise<unknown>
|
||||
random?: () => number
|
||||
} = {}
|
||||
): Promise<RegistryJsonResult> {
|
||||
const attempts = options.attempts ?? 3
|
||||
const wait = options.wait ?? sleep
|
||||
const random = options.random ?? Math.random
|
||||
let result: Awaited<ReturnType<typeof fetchOnce>> | undefined
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
result = await fetchOnce(input, {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maximumBytes: options.maximumBytes ?? DEFAULT_MAX_BODY_BYTES,
|
||||
maximumRedirects: options.maximumRedirects ?? DEFAULT_MAX_REDIRECTS,
|
||||
fetchImpl: options.fetchImpl ?? (undiciFetch as unknown as FetchLike),
|
||||
createDispatcher: options.createDispatcher ?? createGuardedDispatcher,
|
||||
})
|
||||
|
||||
const retryable =
|
||||
!result.ok &&
|
||||
!result.botChallenge &&
|
||||
(result.failureCode === "transport_error" ||
|
||||
result.failureCode === "timeout" ||
|
||||
(result.status !== undefined && isRetryableStatus(result.status)))
|
||||
|
||||
if (!retryable || attempt === attempts - 1) {
|
||||
return result
|
||||
}
|
||||
|
||||
await wait(
|
||||
result.retryAfterMs ??
|
||||
Math.min(1000 * 2 ** attempt + random() * 250, 5000)
|
||||
)
|
||||
}
|
||||
|
||||
return result!
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { registryMonitorEntryStateSchema } from "./schema"
|
||||
|
||||
describe("registryMonitorEntryStateSchema", () => {
|
||||
it("migrates legacy availability recovery state from retained history", () => {
|
||||
const failures = Array.from({ length: 3 }, (_, index) => ({
|
||||
checkedAt: `2026-08-16T0${index}:00:00.000Z`,
|
||||
outcome: "unreachable" as const,
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
}))
|
||||
const recovery = {
|
||||
checkedAt: "2026-08-24T11:00:00.000Z",
|
||||
outcome: "reachable" as const,
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
schemaValid: true,
|
||||
}
|
||||
|
||||
const state = registryMonitorEntryStateSchema.parse({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: recovery.checkedAt,
|
||||
status: "unavailable",
|
||||
itemCursor: 0,
|
||||
itemNames: [],
|
||||
recentIndex: [...failures, recovery],
|
||||
recentDryRuns: [],
|
||||
daily: [],
|
||||
latestHygiene: {
|
||||
contentTypeJson: true,
|
||||
noDuplicateNames: true,
|
||||
nameMatches: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(state).toMatchObject({
|
||||
consecutiveSuccessfulIndexes: 1,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityOutageSince: failures[0].checkedAt,
|
||||
availabilityRecoveryRequired: true,
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves the last success as the start of a legacy outage", () => {
|
||||
const lastSuccessfulCheck = "2026-08-23T08:00:00.000Z"
|
||||
const state = registryMonitorEntryStateSchema.parse({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck,
|
||||
status: "degraded",
|
||||
itemCursor: 0,
|
||||
itemNames: [],
|
||||
recentIndex: Array.from({ length: 3 }, (_, index) => ({
|
||||
checkedAt: `2026-08-24T0${index}:00:00.000Z`,
|
||||
outcome: "unreachable" as const,
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
})),
|
||||
recentDryRuns: [],
|
||||
daily: [],
|
||||
latestHygiene: {
|
||||
contentTypeJson: true,
|
||||
noDuplicateNames: true,
|
||||
nameMatches: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(state).toMatchObject({
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 3,
|
||||
availabilityOutageSince: lastSuccessfulCheck,
|
||||
availabilityRecoveryRequired: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,308 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { registryNamespaceSchema } from "../registry-directory"
|
||||
|
||||
export const REGISTRY_HEALTH_SCHEMA_VERSION = 1 as const
|
||||
export const REGISTRY_HEALTH_SCORE_VERSION = 1 as const
|
||||
|
||||
export const registryHealthStatusSchema = z.enum([
|
||||
"healthy",
|
||||
"observing",
|
||||
"degraded",
|
||||
"unavailable",
|
||||
])
|
||||
|
||||
export const registryHealthStatusReasonCodeSchema = z.enum([
|
||||
"healthy_thresholds",
|
||||
"collecting_baseline",
|
||||
"index_unavailable",
|
||||
"recovery_pending",
|
||||
"consecutive_index_failures",
|
||||
"index_schema_invalid",
|
||||
"item_validation_failures",
|
||||
"dry_run_failures",
|
||||
])
|
||||
|
||||
export const registryHealthBreakdownSchema = z
|
||||
.object({
|
||||
reliability: z.number().min(0).max(45),
|
||||
correctness: z.number().min(0).max(25),
|
||||
installability: z.number().min(0).max(20),
|
||||
hygiene: z.number().min(0).max(10),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryHealthSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||
scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION),
|
||||
status: registryHealthStatusSchema,
|
||||
statusReason: z
|
||||
.object({
|
||||
code: registryHealthStatusReasonCodeSchema,
|
||||
message: z.string().min(1),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
score: z.number().min(0).max(100),
|
||||
breakdown: registryHealthBreakdownSchema,
|
||||
availability7d: z.number().min(0).max(1),
|
||||
availability30d: z.number().min(0).max(1),
|
||||
monitoringLimited: z.boolean(),
|
||||
firstObservedAt: z.string().datetime().optional(),
|
||||
checkedAt: z.string().datetime(),
|
||||
lastSuccessfulCheck: z.string().datetime().optional(),
|
||||
hidden: z.boolean(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryHealthGlobalMeansSchema = z
|
||||
.object({
|
||||
availability7d: z.number().min(0).max(1),
|
||||
availability30d: z.number().min(0).max(1),
|
||||
indexSchema: z.number().min(0).max(1),
|
||||
itemValidity: z.number().min(0).max(1),
|
||||
dryRun: z.number().min(0).max(1),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryHealthSnapshotSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||
scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION),
|
||||
generatedAt: z.string().datetime(),
|
||||
globalMeans: registryHealthGlobalMeansSchema,
|
||||
registries: z.record(registryNamespaceSchema, registryHealthSchema),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryIndexObservationSchema = z
|
||||
.object({
|
||||
checkedAt: z.string().datetime(),
|
||||
outcome: z.enum(["reachable", "unreachable", "bot_challenge"]),
|
||||
status: z.number().int().min(100).max(599).optional(),
|
||||
failureCode: z.string().optional(),
|
||||
durationMs: z.number().int().nonnegative(),
|
||||
responseSize: z.number().int().nonnegative().optional(),
|
||||
redirectCount: z.number().int().nonnegative(),
|
||||
schemaValid: z.boolean().optional(),
|
||||
contentTypeJson: z.boolean().optional(),
|
||||
duplicateNames: z.boolean().optional(),
|
||||
nameMatches: z.boolean().optional(),
|
||||
itemCount: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryItemObservationSchema = z
|
||||
.object({
|
||||
checkedAt: z.string().datetime(),
|
||||
item: z.string(),
|
||||
success: z.boolean(),
|
||||
failureCode: z.string().optional(),
|
||||
durationMs: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryDryRunObservationSchema = z
|
||||
.object({
|
||||
checkedAt: z.string().datetime(),
|
||||
item: z.string(),
|
||||
success: z.boolean(),
|
||||
failureCode: z.string().optional(),
|
||||
durationMs: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryHealthDailyBucketSchema = z
|
||||
.object({
|
||||
date: z.string().date(),
|
||||
availabilitySuccesses: z.number().int().nonnegative(),
|
||||
availabilityObservations: z.number().int().nonnegative(),
|
||||
challengeObservations: z.number().int().nonnegative(),
|
||||
schemaSuccesses: z.number().int().nonnegative(),
|
||||
schemaObservations: z.number().int().nonnegative(),
|
||||
itemSuccesses: z.number().int().nonnegative(),
|
||||
itemObservations: z.number().int().nonnegative(),
|
||||
dryRunSuccesses: z.number().int().nonnegative(),
|
||||
dryRunObservations: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
const registryMonitorEntryStateInputSchema = z
|
||||
.object({
|
||||
firstObservedAt: z.string().datetime(),
|
||||
lastSuccessfulCheck: z.string().datetime().optional(),
|
||||
status: registryHealthStatusSchema,
|
||||
consecutiveSuccessfulIndexes: z.number().int().nonnegative().optional(),
|
||||
consecutiveIndexFailures: z.number().int().nonnegative().optional(),
|
||||
availabilityOutageSince: z.string().datetime().optional(),
|
||||
availabilityRecoveryRequired: z.boolean().optional(),
|
||||
itemCursor: z.number().int().nonnegative(),
|
||||
itemNames: z.array(z.string()),
|
||||
recentIndex: z.array(registryIndexObservationSchema),
|
||||
recentDryRuns: z.array(registryDryRunObservationSchema),
|
||||
daily: z.array(registryHealthDailyBucketSchema),
|
||||
latestHygiene: z
|
||||
.object({
|
||||
contentTypeJson: z.boolean().nullable(),
|
||||
noDuplicateNames: z.boolean().nullable(),
|
||||
nameMatches: z.boolean().nullable(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
function getLegacyAvailabilityState(
|
||||
entry: z.infer<typeof registryMonitorEntryStateInputSchema>
|
||||
) {
|
||||
const observations = entry.recentIndex.filter(
|
||||
(observation) => observation.outcome !== "bot_challenge"
|
||||
)
|
||||
let cursor = observations.length - 1
|
||||
let consecutiveSuccessfulIndexes = 0
|
||||
|
||||
while (cursor >= 0) {
|
||||
const observation = observations[cursor]
|
||||
if (observation.outcome !== "reachable" || !observation.schemaValid) {
|
||||
break
|
||||
}
|
||||
consecutiveSuccessfulIndexes += 1
|
||||
cursor -= 1
|
||||
}
|
||||
|
||||
const failureEnd = cursor
|
||||
let precedingFailures = 0
|
||||
while (cursor >= 0 && observations[cursor].outcome === "unreachable") {
|
||||
precedingFailures += 1
|
||||
cursor -= 1
|
||||
}
|
||||
|
||||
const consecutiveIndexFailures =
|
||||
consecutiveSuccessfulIndexes === 0 ? precedingFailures : 0
|
||||
const recoveryRequired =
|
||||
entry.status === "unavailable" ||
|
||||
(entry.status === "degraded" && precedingFailures >= 3)
|
||||
const failureStart =
|
||||
precedingFailures > 0
|
||||
? observations[failureEnd - precedingFailures + 1]
|
||||
: undefined
|
||||
const outageStart =
|
||||
consecutiveSuccessfulIndexes > 0
|
||||
? failureStart?.checkedAt
|
||||
: (entry.lastSuccessfulCheck ?? failureStart?.checkedAt)
|
||||
const availabilityOutageSince =
|
||||
recoveryRequired || consecutiveIndexFailures > 0
|
||||
? (outageStart ?? entry.firstObservedAt)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
consecutiveSuccessfulIndexes,
|
||||
consecutiveIndexFailures,
|
||||
availabilityOutageSince,
|
||||
availabilityRecoveryRequired: recoveryRequired,
|
||||
}
|
||||
}
|
||||
|
||||
export const registryMonitorEntryStateSchema =
|
||||
registryMonitorEntryStateInputSchema.transform((entry) => {
|
||||
if (
|
||||
entry.consecutiveSuccessfulIndexes !== undefined &&
|
||||
entry.consecutiveIndexFailures !== undefined &&
|
||||
entry.availabilityRecoveryRequired !== undefined
|
||||
) {
|
||||
return entry as typeof entry & {
|
||||
consecutiveSuccessfulIndexes: number
|
||||
consecutiveIndexFailures: number
|
||||
availabilityRecoveryRequired: boolean
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...entry,
|
||||
...getLegacyAvailabilityState(entry),
|
||||
}
|
||||
})
|
||||
|
||||
export const registryMonitorStateSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||
scoreVersion: z.literal(REGISTRY_HEALTH_SCORE_VERSION),
|
||||
updatedAt: z.string().datetime(),
|
||||
lastDailyRunAt: z.string().datetime().optional(),
|
||||
lastWeeklyRunAt: z.string().datetime().optional(),
|
||||
registries: z.record(
|
||||
registryNamespaceSchema,
|
||||
registryMonitorEntryStateSchema
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryMonitorRunSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||
startedAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime(),
|
||||
mode: z.enum(["auto", "hourly", "daily", "weekly", "all"]),
|
||||
totals: z
|
||||
.object({
|
||||
registries: z.number().int().nonnegative(),
|
||||
reachable: z.number().int().nonnegative(),
|
||||
unavailable: z.number().int().nonnegative(),
|
||||
challenges: z.number().int().nonnegative(),
|
||||
itemChecks: z.number().int().nonnegative(),
|
||||
dryRuns: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
results: z.record(
|
||||
registryNamespaceSchema,
|
||||
z
|
||||
.object({
|
||||
index: registryIndexObservationSchema.optional(),
|
||||
items: z.array(registryItemObservationSchema),
|
||||
dryRun: registryDryRunObservationSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
),
|
||||
diagnostics: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryMonitorOutputSchema = z
|
||||
.object({
|
||||
state: registryMonitorStateSchema,
|
||||
snapshot: registryHealthSnapshotSchema,
|
||||
run: registryMonitorRunSchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RegistryHealth = z.infer<typeof registryHealthSchema>
|
||||
export type RegistryHealthStatusReasonCode = z.infer<
|
||||
typeof registryHealthStatusReasonCodeSchema
|
||||
>
|
||||
export type RegistryHealthBreakdown = z.infer<
|
||||
typeof registryHealthBreakdownSchema
|
||||
>
|
||||
export type RegistryHealthGlobalMeans = z.infer<
|
||||
typeof registryHealthGlobalMeansSchema
|
||||
>
|
||||
export type RegistryHealthSnapshot = z.infer<
|
||||
typeof registryHealthSnapshotSchema
|
||||
>
|
||||
export type RegistryIndexObservation = z.infer<
|
||||
typeof registryIndexObservationSchema
|
||||
>
|
||||
export type RegistryItemObservation = z.infer<
|
||||
typeof registryItemObservationSchema
|
||||
>
|
||||
export type RegistryDryRunObservation = z.infer<
|
||||
typeof registryDryRunObservationSchema
|
||||
>
|
||||
export type RegistryHealthDailyBucket = z.infer<
|
||||
typeof registryHealthDailyBucketSchema
|
||||
>
|
||||
export type RegistryMonitorEntryState = z.infer<
|
||||
typeof registryMonitorEntryStateSchema
|
||||
>
|
||||
export type RegistryMonitorState = z.infer<typeof registryMonitorStateSchema>
|
||||
export type RegistryMonitorRun = z.infer<typeof registryMonitorRunSchema>
|
||||
export type RegistryMonitorOutput = z.infer<typeof registryMonitorOutputSchema>
|
||||
@@ -0,0 +1,393 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import type {
|
||||
RegistryHealthDailyBucket,
|
||||
RegistryIndexObservation,
|
||||
RegistryMonitorEntryState,
|
||||
} from "./schema"
|
||||
import {
|
||||
calculateRegistryHealth,
|
||||
DEFAULT_GLOBAL_MEANS,
|
||||
getRegistryHealthSignals,
|
||||
} from "./score"
|
||||
|
||||
const NOW = new Date("2026-08-24T12:00:00.000Z")
|
||||
|
||||
function createDailyBucket(overrides: Partial<RegistryHealthDailyBucket> = {}) {
|
||||
return {
|
||||
date: "2026-08-24",
|
||||
availabilitySuccesses: 0,
|
||||
availabilityObservations: 0,
|
||||
challengeObservations: 0,
|
||||
schemaSuccesses: 0,
|
||||
schemaObservations: 0,
|
||||
itemSuccesses: 0,
|
||||
itemObservations: 0,
|
||||
dryRunSuccesses: 0,
|
||||
dryRunObservations: 0,
|
||||
...overrides,
|
||||
} satisfies RegistryHealthDailyBucket
|
||||
}
|
||||
|
||||
function createIndexObservations(
|
||||
count = 25,
|
||||
outcome: RegistryIndexObservation["outcome"] = "reachable"
|
||||
) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
checkedAt: new Date(
|
||||
NOW.getTime() - (count - index) * 60 * 60 * 1000
|
||||
).toISOString(),
|
||||
outcome,
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
schemaValid: outcome === "reachable",
|
||||
})) satisfies RegistryIndexObservation[]
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<RegistryMonitorEntryState> = {}) {
|
||||
return {
|
||||
firstObservedAt: "2026-08-22T10:00:00.000Z",
|
||||
lastSuccessfulCheck: "2026-08-24T11:00:00.000Z",
|
||||
status: "healthy",
|
||||
consecutiveSuccessfulIndexes: 25,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityRecoveryRequired: false,
|
||||
itemCursor: 0,
|
||||
itemNames: [],
|
||||
recentIndex: createIndexObservations(),
|
||||
recentDryRuns: [],
|
||||
daily: [
|
||||
createDailyBucket({
|
||||
availabilitySuccesses: 25,
|
||||
availabilityObservations: 25,
|
||||
schemaSuccesses: 25,
|
||||
schemaObservations: 25,
|
||||
}),
|
||||
],
|
||||
latestHygiene: {
|
||||
contentTypeJson: null,
|
||||
noDuplicateNames: null,
|
||||
nameMatches: null,
|
||||
},
|
||||
...overrides,
|
||||
} satisfies RegistryMonitorEntryState
|
||||
}
|
||||
|
||||
describe("calculateRegistryHealth", () => {
|
||||
it("derives the published total from rounded components", () => {
|
||||
const health = calculateRegistryHealth({
|
||||
state: createState(),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.score).toBe(
|
||||
health.breakdown.reliability +
|
||||
health.breakdown.correctness +
|
||||
health.breakdown.installability +
|
||||
health.breakdown.hygiene
|
||||
)
|
||||
expect(health.score).toBeGreaterThanOrEqual(0)
|
||||
expect(health.score).toBeLessThanOrEqual(100)
|
||||
expect(health.statusReason).toEqual({
|
||||
code: "healthy_thresholds",
|
||||
message: "Recent checks are within healthy thresholds",
|
||||
})
|
||||
})
|
||||
|
||||
it("explains when baseline observations are still being collected", () => {
|
||||
const health = calculateRegistryHealth({
|
||||
state: createState({ recentIndex: createIndexObservations(3) }),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("observing")
|
||||
expect(health.statusReason).toEqual({
|
||||
code: "collecting_baseline",
|
||||
message: "Collecting baseline data (3 of 24 checks)",
|
||||
})
|
||||
})
|
||||
|
||||
it("gives unknown hygiene signals half credit", () => {
|
||||
const health = calculateRegistryHealth({
|
||||
state: createState(),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.breakdown.hygiene).toBe(6.25)
|
||||
})
|
||||
|
||||
it("uses a cadence-scaled prior for weekly dry runs", () => {
|
||||
const state = createState({
|
||||
daily: [
|
||||
createDailyBucket({
|
||||
availabilitySuccesses: 25,
|
||||
availabilityObservations: 25,
|
||||
schemaSuccesses: 25,
|
||||
schemaObservations: 25,
|
||||
dryRunSuccesses: 1,
|
||||
dryRunObservations: 1,
|
||||
}),
|
||||
],
|
||||
})
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.breakdown.installability).toBe(18.5)
|
||||
})
|
||||
|
||||
it("excludes challenge observations from availability", () => {
|
||||
const state = createState({
|
||||
recentIndex: [
|
||||
...createIndexObservations(),
|
||||
{
|
||||
checkedAt: NOW.toISOString(),
|
||||
outcome: "bot_challenge",
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
const signals = getRegistryHealthSignals(
|
||||
state,
|
||||
"https://example.com/{name}.json",
|
||||
NOW
|
||||
)
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(signals.availability7d).toEqual({
|
||||
successes: 25,
|
||||
observations: 25,
|
||||
})
|
||||
expect(health.monitoringLimited).toBe(true)
|
||||
expect(health.status).toBe("healthy")
|
||||
})
|
||||
|
||||
it("hides a registry only after seven days unavailable", () => {
|
||||
const state = createState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: "2026-08-16T00:00:00.000Z",
|
||||
status: "unavailable",
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 8,
|
||||
availabilityOutageSince: "2026-08-16T00:00:00.000Z",
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: createIndexObservations(8, "unreachable"),
|
||||
})
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("unavailable")
|
||||
expect(health.statusReason).toEqual({
|
||||
code: "index_unavailable",
|
||||
message: "No successful index check in the last 24 hours",
|
||||
})
|
||||
expect(health.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps an unavailable registry visible before seven days", () => {
|
||||
const state = createState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: "2026-08-20T00:00:00.000Z",
|
||||
status: "unavailable",
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 8,
|
||||
availabilityOutageSince: "2026-08-20T00:00:00.000Z",
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: createIndexObservations(8, "unreachable"),
|
||||
})
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("unavailable")
|
||||
expect(health.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps a long outage hidden through the first recovery success", () => {
|
||||
const recovery = createIndexObservations(1)[0]
|
||||
const state = createState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: recovery.checkedAt,
|
||||
status: "unavailable",
|
||||
consecutiveSuccessfulIndexes: 1,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityOutageSince: "2026-08-16T00:00:00.000Z",
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: [...createIndexObservations(24, "unreachable"), recovery],
|
||||
})
|
||||
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("unavailable")
|
||||
expect(health.statusReason?.code).toBe("recovery_pending")
|
||||
expect(health.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it("requires two successful checks to recover from availability degradation", () => {
|
||||
const history = createIndexObservations()
|
||||
const failures = createIndexObservations(3, "unreachable")
|
||||
const successes = createIndexObservations(2)
|
||||
const state = createState({
|
||||
status: "degraded",
|
||||
consecutiveSuccessfulIndexes: 1,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityOutageSince: failures[0].checkedAt,
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: [...history, ...failures, successes[0]],
|
||||
})
|
||||
|
||||
const recovering = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
const recovered = calculateRegistryHealth({
|
||||
state: {
|
||||
...state,
|
||||
consecutiveSuccessfulIndexes: 2,
|
||||
availabilityOutageSince: undefined,
|
||||
availabilityRecoveryRequired: false,
|
||||
recentIndex: [...state.recentIndex, successes[1]],
|
||||
},
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(recovering.status).toBe("degraded")
|
||||
expect(recovering.statusReason).toEqual({
|
||||
code: "recovery_pending",
|
||||
message: "Waiting for a second successful recovery check",
|
||||
})
|
||||
expect(recovered.status).toBe("healthy")
|
||||
})
|
||||
|
||||
it("explains each degraded status condition", () => {
|
||||
const failures = calculateRegistryHealth({
|
||||
state: createState({
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 3,
|
||||
availabilityOutageSince: "2026-08-24T09:00:00.000Z",
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: [
|
||||
...createIndexObservations(),
|
||||
...createIndexObservations(3, "unreachable"),
|
||||
],
|
||||
}),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
const observations = createIndexObservations()
|
||||
const invalidSchema = calculateRegistryHealth({
|
||||
state: createState({
|
||||
recentIndex: [
|
||||
...observations.slice(0, -1),
|
||||
{ ...observations.at(-1)!, schemaValid: false },
|
||||
],
|
||||
}),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
const invalidItems = calculateRegistryHealth({
|
||||
state: createState({
|
||||
daily: [
|
||||
createDailyBucket({
|
||||
availabilitySuccesses: 25,
|
||||
availabilityObservations: 25,
|
||||
schemaSuccesses: 25,
|
||||
schemaObservations: 25,
|
||||
itemSuccesses: 8,
|
||||
itemObservations: 10,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
const dryRuns = calculateRegistryHealth({
|
||||
state: createState({
|
||||
recentDryRuns: [
|
||||
{
|
||||
checkedAt: "2026-08-17T12:00:00.000Z",
|
||||
item: "button",
|
||||
success: false,
|
||||
durationMs: 100,
|
||||
},
|
||||
{
|
||||
checkedAt: "2026-08-24T12:00:00.000Z",
|
||||
item: "button",
|
||||
success: false,
|
||||
durationMs: 100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(failures.statusReason).toEqual({
|
||||
code: "consecutive_index_failures",
|
||||
message: "The registry index failed 3 consecutive checks",
|
||||
})
|
||||
expect(invalidSchema.statusReason).toEqual({
|
||||
code: "index_schema_invalid",
|
||||
message: "The registry index failed schema validation",
|
||||
})
|
||||
expect(invalidItems.statusReason).toEqual({
|
||||
code: "item_validation_failures",
|
||||
message: "Sampled registry items are failing validation",
|
||||
})
|
||||
expect(dryRuns.statusReason).toEqual({
|
||||
code: "dry_run_failures",
|
||||
message: "The last two CLI dry-run checks failed",
|
||||
})
|
||||
})
|
||||
|
||||
it("recovers correctness degradation when its condition clears", () => {
|
||||
const state = createState({
|
||||
status: "degraded",
|
||||
recentIndex: createIndexObservations(),
|
||||
})
|
||||
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("healthy")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
REGISTRY_HEALTH_SCORE_VERSION,
|
||||
type RegistryHealth,
|
||||
type RegistryHealthGlobalMeans,
|
||||
type RegistryMonitorEntryState,
|
||||
type RegistryMonitorState,
|
||||
} from "./schema"
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const DEFAULT_GLOBAL_MEANS: RegistryHealthGlobalMeans = {
|
||||
availability7d: 0.85,
|
||||
availability30d: 0.85,
|
||||
indexSchema: 0.9,
|
||||
itemValidity: 0.9,
|
||||
dryRun: 0.9,
|
||||
}
|
||||
|
||||
const PRIOR_WEIGHTS = {
|
||||
availability7d: 24,
|
||||
availability30d: 24,
|
||||
indexSchema: 12,
|
||||
itemValidity: 10,
|
||||
dryRun: 3,
|
||||
} as const
|
||||
|
||||
type RateSignal = {
|
||||
successes: number
|
||||
observations: number
|
||||
}
|
||||
|
||||
export type RegistryHealthSignals = {
|
||||
availability7d: RateSignal
|
||||
availability30d: RateSignal
|
||||
indexSchema: RateSignal
|
||||
itemValidity: RateSignal
|
||||
dryRun: RateSignal
|
||||
hygiene: {
|
||||
https: boolean
|
||||
contentTypeJson: boolean | null
|
||||
noDuplicateNames: boolean | null
|
||||
nameMatches: boolean | null
|
||||
}
|
||||
}
|
||||
|
||||
function round(value: number, digits: number) {
|
||||
const factor = 10 ** digits
|
||||
return Math.round((value + Number.EPSILON) * factor) / factor
|
||||
}
|
||||
|
||||
function isWithinWindow(timestamp: string, now: Date, days: number) {
|
||||
const age = now.getTime() - new Date(timestamp).getTime()
|
||||
return age >= 0 && age < days * DAY_MS
|
||||
}
|
||||
|
||||
function isBucketWithinWindow(date: string, now: Date, days: number) {
|
||||
return isWithinWindow(`${date}T00:00:00.000Z`, now, days)
|
||||
}
|
||||
|
||||
function sumDailySignal(
|
||||
state: RegistryMonitorEntryState,
|
||||
now: Date,
|
||||
days: number,
|
||||
successesKey:
|
||||
| "availabilitySuccesses"
|
||||
| "schemaSuccesses"
|
||||
| "itemSuccesses"
|
||||
| "dryRunSuccesses",
|
||||
observationsKey:
|
||||
| "availabilityObservations"
|
||||
| "schemaObservations"
|
||||
| "itemObservations"
|
||||
| "dryRunObservations"
|
||||
) {
|
||||
return state.daily.reduce<RateSignal>(
|
||||
(signal, bucket) => {
|
||||
if (!isBucketWithinWindow(bucket.date, now, days)) {
|
||||
return signal
|
||||
}
|
||||
|
||||
signal.successes += bucket[successesKey]
|
||||
signal.observations += bucket[observationsKey]
|
||||
return signal
|
||||
},
|
||||
{ successes: 0, observations: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
export function getRegistryHealthSignals(
|
||||
state: RegistryMonitorEntryState,
|
||||
registryUrl: string,
|
||||
now: Date
|
||||
): RegistryHealthSignals {
|
||||
const availability7d = state.recentIndex.reduce<RateSignal>(
|
||||
(signal, observation) => {
|
||||
if (
|
||||
observation.outcome === "bot_challenge" ||
|
||||
!isWithinWindow(observation.checkedAt, now, 7)
|
||||
) {
|
||||
return signal
|
||||
}
|
||||
|
||||
signal.observations += 1
|
||||
signal.successes += observation.outcome === "reachable" ? 1 : 0
|
||||
return signal
|
||||
},
|
||||
{ successes: 0, observations: 0 }
|
||||
)
|
||||
|
||||
return {
|
||||
availability7d,
|
||||
availability30d: sumDailySignal(
|
||||
state,
|
||||
now,
|
||||
30,
|
||||
"availabilitySuccesses",
|
||||
"availabilityObservations"
|
||||
),
|
||||
indexSchema: sumDailySignal(
|
||||
state,
|
||||
now,
|
||||
30,
|
||||
"schemaSuccesses",
|
||||
"schemaObservations"
|
||||
),
|
||||
itemValidity: sumDailySignal(
|
||||
state,
|
||||
now,
|
||||
30,
|
||||
"itemSuccesses",
|
||||
"itemObservations"
|
||||
),
|
||||
dryRun: sumDailySignal(
|
||||
state,
|
||||
now,
|
||||
30,
|
||||
"dryRunSuccesses",
|
||||
"dryRunObservations"
|
||||
),
|
||||
hygiene: {
|
||||
https: registryUrl.startsWith("https://"),
|
||||
contentTypeJson: state.latestHygiene.contentTypeJson,
|
||||
noDuplicateNames: state.latestHygiene.noDuplicateNames,
|
||||
nameMatches: state.latestHygiene.nameMatches,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getObservedMean(
|
||||
signals: RateSignal[],
|
||||
minimumObservations: number,
|
||||
fallback: number
|
||||
) {
|
||||
const eligible = signals.filter(
|
||||
(signal) => signal.observations >= minimumObservations
|
||||
)
|
||||
const observations = eligible.reduce(
|
||||
(total, signal) => total + signal.observations,
|
||||
0
|
||||
)
|
||||
|
||||
if (observations === 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const successes = eligible.reduce(
|
||||
(total, signal) => total + signal.successes,
|
||||
0
|
||||
)
|
||||
return successes / observations
|
||||
}
|
||||
|
||||
export function calculateGlobalMeans(
|
||||
state: RegistryMonitorState,
|
||||
registryUrls: Record<string, string>,
|
||||
now: Date
|
||||
): RegistryHealthGlobalMeans {
|
||||
const signals = Object.entries(state.registries).map(([name, entry]) =>
|
||||
getRegistryHealthSignals(entry, registryUrls[name] ?? "http://invalid", now)
|
||||
)
|
||||
|
||||
return {
|
||||
availability7d: getObservedMean(
|
||||
signals.map((signal) => signal.availability7d),
|
||||
24,
|
||||
DEFAULT_GLOBAL_MEANS.availability7d
|
||||
),
|
||||
availability30d: getObservedMean(
|
||||
signals.map((signal) => signal.availability30d),
|
||||
24,
|
||||
DEFAULT_GLOBAL_MEANS.availability30d
|
||||
),
|
||||
indexSchema: getObservedMean(
|
||||
signals.map((signal) => signal.indexSchema),
|
||||
12,
|
||||
DEFAULT_GLOBAL_MEANS.indexSchema
|
||||
),
|
||||
itemValidity: getObservedMean(
|
||||
signals.map((signal) => signal.itemValidity),
|
||||
10,
|
||||
DEFAULT_GLOBAL_MEANS.itemValidity
|
||||
),
|
||||
dryRun: getObservedMean(
|
||||
signals.map((signal) => signal.dryRun),
|
||||
3,
|
||||
DEFAULT_GLOBAL_MEANS.dryRun
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function smooth(signal: RateSignal, globalMean: number, priorWeight: number) {
|
||||
return (
|
||||
(signal.successes + globalMean * priorWeight) /
|
||||
(signal.observations + priorWeight)
|
||||
)
|
||||
}
|
||||
|
||||
function getHygienePoints(value: boolean | null) {
|
||||
if (value === null) {
|
||||
return 1.25
|
||||
}
|
||||
|
||||
return value ? 2.5 : 0
|
||||
}
|
||||
|
||||
function getNonChallengeObservations(state: RegistryMonitorEntryState) {
|
||||
return state.recentIndex.filter(
|
||||
(observation) => observation.outcome !== "bot_challenge"
|
||||
)
|
||||
}
|
||||
|
||||
function getUnavailableSince(state: RegistryMonitorEntryState) {
|
||||
return state.availabilityOutageSince ?? state.firstObservedAt
|
||||
}
|
||||
|
||||
function deriveStatus(
|
||||
state: RegistryMonitorEntryState,
|
||||
signals: RegistryHealthSignals,
|
||||
now: Date
|
||||
) {
|
||||
const observations = getNonChallengeObservations(state)
|
||||
const latest = observations.at(-1)
|
||||
const unavailableFor =
|
||||
now.getTime() - new Date(getUnavailableSince(state)).getTime()
|
||||
const unavailable =
|
||||
unavailableFor >= DAY_MS &&
|
||||
(state.availabilityRecoveryRequired || latest?.outcome === "unreachable")
|
||||
|
||||
if (unavailable) {
|
||||
if (latest?.outcome === "reachable") {
|
||||
return {
|
||||
status: "unavailable" as const,
|
||||
statusReason: {
|
||||
code: "recovery_pending" as const,
|
||||
message: "Waiting for a second successful recovery check",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "unavailable" as const,
|
||||
statusReason: {
|
||||
code: "index_unavailable" as const,
|
||||
message: "No successful index check in the last 24 hours",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const observationSpan = latest
|
||||
? new Date(latest.checkedAt).getTime() -
|
||||
new Date(state.firstObservedAt).getTime()
|
||||
: 0
|
||||
|
||||
if (observations.length < 24 || observationSpan < DAY_MS) {
|
||||
return {
|
||||
status: "observing" as const,
|
||||
statusReason: {
|
||||
code: "collecting_baseline" as const,
|
||||
message:
|
||||
observations.length < 24
|
||||
? `Collecting baseline data (${observations.length} of 24 checks)`
|
||||
: "Collecting a full day of baseline data",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const trailingFailures = state.consecutiveIndexFailures
|
||||
const latestSchemaInvalid =
|
||||
latest?.outcome === "reachable" && latest.schemaValid === false
|
||||
const itemDegraded =
|
||||
signals.itemValidity.observations >= 10 &&
|
||||
signals.itemValidity.successes / signals.itemValidity.observations < 0.9
|
||||
const recentDryRuns = state.recentDryRuns.slice(-2)
|
||||
const dryRunDegraded =
|
||||
recentDryRuns.length === 2 &&
|
||||
recentDryRuns.every((observation) => !observation.success)
|
||||
|
||||
if (trailingFailures >= 3) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "consecutive_index_failures" as const,
|
||||
message: `The registry index failed ${trailingFailures} consecutive checks`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (latestSchemaInvalid) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "index_schema_invalid" as const,
|
||||
message: "The registry index failed schema validation",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (itemDegraded) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "item_validation_failures" as const,
|
||||
message: "Sampled registry items are failing validation",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRunDegraded) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "dry_run_failures" as const,
|
||||
message: "The last two CLI dry-run checks failed",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (state.availabilityRecoveryRequired) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "recovery_pending" as const,
|
||||
message: "Waiting for a second successful recovery check",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "healthy" as const,
|
||||
statusReason: {
|
||||
code: "healthy_thresholds" as const,
|
||||
message: "Recent checks are within healthy thresholds",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl,
|
||||
globalMeans,
|
||||
now,
|
||||
}: {
|
||||
state: RegistryMonitorEntryState
|
||||
registryUrl: string
|
||||
globalMeans: RegistryHealthGlobalMeans
|
||||
now: Date
|
||||
}): RegistryHealth {
|
||||
const signals = getRegistryHealthSignals(state, registryUrl, now)
|
||||
const availability7d = smooth(
|
||||
signals.availability7d,
|
||||
globalMeans.availability7d,
|
||||
PRIOR_WEIGHTS.availability7d
|
||||
)
|
||||
const availability30d = smooth(
|
||||
signals.availability30d,
|
||||
globalMeans.availability30d,
|
||||
PRIOR_WEIGHTS.availability30d
|
||||
)
|
||||
const indexSchema = smooth(
|
||||
signals.indexSchema,
|
||||
globalMeans.indexSchema,
|
||||
PRIOR_WEIGHTS.indexSchema
|
||||
)
|
||||
const itemValidity = smooth(
|
||||
signals.itemValidity,
|
||||
globalMeans.itemValidity,
|
||||
PRIOR_WEIGHTS.itemValidity
|
||||
)
|
||||
const dryRun = smooth(
|
||||
signals.dryRun,
|
||||
globalMeans.dryRun,
|
||||
PRIOR_WEIGHTS.dryRun
|
||||
)
|
||||
|
||||
const breakdown = {
|
||||
reliability: round(
|
||||
45 * (0.65 * availability7d + 0.35 * availability30d),
|
||||
3
|
||||
),
|
||||
correctness: round(10 * indexSchema + 15 * itemValidity, 3),
|
||||
installability: round(20 * dryRun, 3),
|
||||
hygiene: round(
|
||||
(signals.hygiene.https ? 2.5 : 0) +
|
||||
getHygienePoints(signals.hygiene.contentTypeJson) +
|
||||
getHygienePoints(signals.hygiene.noDuplicateNames) +
|
||||
getHygienePoints(signals.hygiene.nameMatches),
|
||||
3
|
||||
),
|
||||
}
|
||||
const score = round(
|
||||
breakdown.reliability +
|
||||
breakdown.correctness +
|
||||
breakdown.installability +
|
||||
breakdown.hygiene,
|
||||
3
|
||||
)
|
||||
const { status, statusReason } = deriveStatus(state, signals, now)
|
||||
const unavailableFor =
|
||||
now.getTime() - new Date(getUnavailableSince(state)).getTime()
|
||||
const hidden = status === "unavailable" && unavailableFor >= 7 * DAY_MS
|
||||
const latestObservation = state.recentIndex.at(-1)
|
||||
const checkedAt = latestObservation?.checkedAt ?? state.firstObservedAt
|
||||
|
||||
return {
|
||||
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
scoreVersion: REGISTRY_HEALTH_SCORE_VERSION,
|
||||
status,
|
||||
statusReason,
|
||||
score,
|
||||
breakdown,
|
||||
availability7d: round(availability7d, 6),
|
||||
availability30d: round(availability30d, 6),
|
||||
monitoringLimited: latestObservation?.outcome === "bot_challenge",
|
||||
firstObservedAt: state.firstObservedAt,
|
||||
checkedAt,
|
||||
lastSuccessfulCheck: state.lastSuccessfulCheck,
|
||||
hidden,
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_GLOBAL_MEANS, PRIOR_WEIGHTS }
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { RegistryMonitorEntryState } from "./schema"
|
||||
|
||||
function createRegistryMonitorEntryState(now: Date) {
|
||||
return {
|
||||
firstObservedAt: now.toISOString(),
|
||||
status: "observing",
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityRecoveryRequired: false,
|
||||
itemCursor: 0,
|
||||
itemNames: [],
|
||||
recentIndex: [],
|
||||
recentDryRuns: [],
|
||||
daily: [],
|
||||
latestHygiene: {
|
||||
contentTypeJson: null,
|
||||
noDuplicateNames: null,
|
||||
nameMatches: null,
|
||||
},
|
||||
} satisfies RegistryMonitorEntryState
|
||||
}
|
||||
|
||||
export { createRegistryMonitorEntryState }
|
||||
@@ -18,6 +18,10 @@
|
||||
"registry:build": "pnpm --filter=@shadcn/react build && pnpm --filter=@shadcn/helpers build && pnpm --filter=shadcn build && bun run ./scripts/build-registry.mts",
|
||||
"registry: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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { promises as fs } from "node:fs"
|
||||
import path from "node:path"
|
||||
|
||||
import { registryDirectorySchema } from "../lib/registry-directory"
|
||||
import {
|
||||
cleanRegistryHealthHistory,
|
||||
loadRegistryMonitorState,
|
||||
publishRegistryHealth,
|
||||
} from "../lib/registry-health/blob"
|
||||
import { runLocalCliDryRun } from "../lib/registry-health/dry-run"
|
||||
import {
|
||||
runRegistryMonitor,
|
||||
type RegistryMonitorMode,
|
||||
} from "../lib/registry-health/monitor"
|
||||
import {
|
||||
registryMonitorOutputSchema,
|
||||
registryMonitorStateSchema,
|
||||
} from "../lib/registry-health/schema"
|
||||
import directory from "../registry/directory.json"
|
||||
|
||||
const PHASES = new Set(["prepare", "check", "publish"] as const)
|
||||
const MODES = new Set<RegistryMonitorMode>([
|
||||
"auto",
|
||||
"hourly",
|
||||
"daily",
|
||||
"weekly",
|
||||
"all",
|
||||
])
|
||||
const WORK_DIRECTORY = path.resolve(process.cwd(), ".registry-health")
|
||||
const PREVIOUS_STATE_PATH = path.join(WORK_DIRECTORY, "previous-state.json")
|
||||
const MONITOR_OUTPUT_PATH = path.join(WORK_DIRECTORY, "monitor-output.json")
|
||||
const BLOB_ENVIRONMENT_VARIABLES = [
|
||||
"BLOB_READ_WRITE_TOKEN",
|
||||
"VERCEL_OIDC_TOKEN",
|
||||
"BLOB_STORE_ID",
|
||||
] as const
|
||||
|
||||
type MonitorPhase = "prepare" | "check" | "publish"
|
||||
|
||||
function getArgument(name: string) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index >= 0) {
|
||||
const value = process.argv[index + 1]
|
||||
if (!value || value.startsWith("--")) {
|
||||
throw new Error(`Missing value for ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const argument = process.argv.find((value) => value.startsWith(`${name}=`))
|
||||
if (!argument) return undefined
|
||||
|
||||
const value = argument.slice(name.length + 1)
|
||||
if (!value) {
|
||||
throw new Error(`Missing value for ${name}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function getPhase() {
|
||||
const value = getArgument("--phase")
|
||||
if (!value) return null
|
||||
|
||||
if (!PHASES.has(value as MonitorPhase)) {
|
||||
throw new Error(`Invalid monitor phase: ${value}`)
|
||||
}
|
||||
|
||||
return value as MonitorPhase
|
||||
}
|
||||
|
||||
function getMode() {
|
||||
const value = getArgument("--mode") ?? process.env.MONITOR_MODE
|
||||
const mode = (value ?? "auto") as RegistryMonitorMode
|
||||
|
||||
if (!MODES.has(mode)) {
|
||||
throw new Error(`Invalid monitor mode: ${value}`)
|
||||
}
|
||||
|
||||
return mode
|
||||
}
|
||||
|
||||
function getBlobToken() {
|
||||
const token = process.env.BLOB_READ_WRITE_TOKEN
|
||||
const hasVercelOidc =
|
||||
process.env.VERCEL_OIDC_TOKEN && process.env.BLOB_STORE_ID
|
||||
|
||||
if (!token && !hasVercelOidc) {
|
||||
throw new Error(
|
||||
"Blob authentication is missing. Configure BLOB_READ_WRITE_TOKEN outside Vercel or Vercel Blob OIDC inside a linked project."
|
||||
)
|
||||
}
|
||||
|
||||
return token
|
||||
}
|
||||
|
||||
function assertCredentialFreeCheck() {
|
||||
const exposed = BLOB_ENVIRONMENT_VARIABLES.filter((name) => process.env[name])
|
||||
|
||||
if (exposed.length > 0) {
|
||||
throw new Error(
|
||||
`The check phase must not receive Blob credentials: ${exposed.join(", ")}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function readJson(pathname: string) {
|
||||
return JSON.parse(await fs.readFile(pathname, "utf8")) as unknown
|
||||
}
|
||||
|
||||
async function writeJson(pathname: string, value: unknown) {
|
||||
await fs.mkdir(path.dirname(pathname), { recursive: true })
|
||||
const temporaryPath = `${pathname}.${process.pid}.tmp`
|
||||
await fs.writeFile(temporaryPath, JSON.stringify(value))
|
||||
await fs.rename(temporaryPath, pathname)
|
||||
}
|
||||
|
||||
async function writeStepSummary({
|
||||
latestPath,
|
||||
mode,
|
||||
totals,
|
||||
diagnostics,
|
||||
deleted,
|
||||
}: {
|
||||
latestPath: string
|
||||
mode: RegistryMonitorMode
|
||||
totals: {
|
||||
registries: number
|
||||
reachable: number
|
||||
unavailable: number
|
||||
challenges: number
|
||||
itemChecks: number
|
||||
dryRuns: number
|
||||
}
|
||||
diagnostics: string[]
|
||||
deleted: number
|
||||
}) {
|
||||
if (!process.env.GITHUB_STEP_SUMMARY) return
|
||||
|
||||
const rows = [
|
||||
"## Registry health monitor",
|
||||
"",
|
||||
`- Mode: \`${mode}\``,
|
||||
`- Registries: ${totals.registries}`,
|
||||
`- Reachable indexes: ${totals.reachable}`,
|
||||
`- Unavailable indexes: ${totals.unavailable}`,
|
||||
`- Bot challenges: ${totals.challenges}`,
|
||||
`- Item checks: ${totals.itemChecks}`,
|
||||
`- Dry runs: ${totals.dryRuns}`,
|
||||
`- Expired history deleted: ${deleted}`,
|
||||
`- Latest snapshot: ${latestPath}`,
|
||||
]
|
||||
|
||||
if (diagnostics.length > 0) {
|
||||
rows.push("", "### Diagnostics", "")
|
||||
rows.push(...diagnostics.map((diagnostic) => `- ${diagnostic}`))
|
||||
}
|
||||
|
||||
await fs.appendFile(process.env.GITHUB_STEP_SUMMARY, `${rows.join("\n")}\n`)
|
||||
}
|
||||
|
||||
async function prepareMonitor() {
|
||||
const token = getBlobToken()
|
||||
const previousState = await loadRegistryMonitorState({ token })
|
||||
|
||||
await fs.rm(MONITOR_OUTPUT_PATH, { force: true })
|
||||
await writeJson(PREVIOUS_STATE_PATH, previousState)
|
||||
console.log(
|
||||
previousState
|
||||
? "Registry health state downloaded."
|
||||
: "No previous registry health state found."
|
||||
)
|
||||
}
|
||||
|
||||
async function checkRegistries() {
|
||||
assertCredentialFreeCheck()
|
||||
|
||||
const previousStateValue = await readJson(PREVIOUS_STATE_PATH)
|
||||
const previousState =
|
||||
previousStateValue === null
|
||||
? null
|
||||
: registryMonitorStateSchema.parse(previousStateValue)
|
||||
const parsedDirectory = registryDirectorySchema.parse(directory)
|
||||
const cliPath = path.resolve(
|
||||
process.cwd(),
|
||||
"../../packages/shadcn/dist/index.js"
|
||||
)
|
||||
const result = await runRegistryMonitor({
|
||||
directory: parsedDirectory,
|
||||
previousState,
|
||||
mode: getMode(),
|
||||
runDryRun: (options) => runLocalCliDryRun({ ...options, cliPath }),
|
||||
})
|
||||
|
||||
await writeJson(MONITOR_OUTPUT_PATH, result)
|
||||
console.log(
|
||||
`Registry checks completed for ${result.run.totals.registries} registries.`
|
||||
)
|
||||
}
|
||||
|
||||
async function publishMonitor() {
|
||||
const token = getBlobToken()
|
||||
const result = registryMonitorOutputSchema.parse(
|
||||
await readJson(MONITOR_OUTPUT_PATH)
|
||||
)
|
||||
const publication = await publishRegistryHealth({
|
||||
...result,
|
||||
token,
|
||||
})
|
||||
const cleanup = await cleanRegistryHealthHistory({ token })
|
||||
|
||||
await writeStepSummary({
|
||||
latestPath: publication.latestPath,
|
||||
mode: result.run.mode,
|
||||
totals: result.run.totals,
|
||||
diagnostics: result.run.diagnostics,
|
||||
deleted: cleanup.deleted,
|
||||
})
|
||||
await fs.rm(WORK_DIRECTORY, { recursive: true, force: true })
|
||||
console.log(`Registry health snapshot published: ${publication.latestPath}`)
|
||||
}
|
||||
|
||||
async function runPackageScript(
|
||||
script: string,
|
||||
environment: NodeJS.ProcessEnv
|
||||
) {
|
||||
const isWindows = process.platform === "win32"
|
||||
const command = isWindows ? "pnpm.cmd" : "pnpm"
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(command, ["run", script], {
|
||||
cwd: process.cwd(),
|
||||
env: environment,
|
||||
shell: isWindows,
|
||||
stdio: "inherit",
|
||||
})
|
||||
|
||||
child.on("error", reject)
|
||||
child.on("exit", (code, signal) => {
|
||||
if (code === 0) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
reject(
|
||||
new Error(
|
||||
`${script} failed with ${signal ? `signal ${signal}` : `exit code ${code}`}`
|
||||
)
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function runAllPhases() {
|
||||
await runPackageScript("registry:health:prepare", process.env)
|
||||
|
||||
const checkEnvironment = { ...process.env }
|
||||
for (const name of BLOB_ENVIRONMENT_VARIABLES) {
|
||||
delete checkEnvironment[name]
|
||||
}
|
||||
delete checkEnvironment.GITHUB_STEP_SUMMARY
|
||||
|
||||
await runPackageScript("registry:health:check", checkEnvironment)
|
||||
await runPackageScript("registry:health:publish", process.env)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const phase = getPhase()
|
||||
|
||||
if (!phase) {
|
||||
await runAllPhases()
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === "prepare") {
|
||||
await prepareMonitor()
|
||||
return
|
||||
}
|
||||
|
||||
if (phase === "check") {
|
||||
await checkRegistries()
|
||||
return
|
||||
}
|
||||
|
||||
await publishMonitor()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
"Registry health monitor failed:",
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
)
|
||||
process.exitCode = 1
|
||||
})
|
||||
@@ -1,32 +1,11 @@
|
||||
import { promises as fs } from "fs"
|
||||
import 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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+107
-1
@@ -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
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user