snapshot of https://github.com/shadcn-ui/ui.git (main, 2026-09-15; blobs > 131072B omitted)
This commit is contained in:
13
apps/v4/lib/ai.ts
Normal file
13
apps/v4/lib/ai.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Compatibility bridge for v4 examples that still import from `@/lib/ai`.
|
||||
// The public chat API lives in `@shadcn/helpers/ai-sdk`; `getMessageText`
|
||||
// remains local to the app.
|
||||
import type { UIMessage } from "ai"
|
||||
|
||||
export * from "@shadcn/helpers/ai-sdk"
|
||||
|
||||
export function getMessageText(message: Pick<UIMessage, "parts">) {
|
||||
return message.parts.reduce(
|
||||
(text, part) => (part.type === "text" ? text + part.text : text),
|
||||
""
|
||||
)
|
||||
}
|
||||
72
apps/v4/lib/blocks.ts
Normal file
72
apps/v4/lib/blocks.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
"use server"
|
||||
|
||||
import { registryItemSchema, registrySchema } from "shadcn/schema"
|
||||
import { type z } from "zod"
|
||||
|
||||
import { registry as baseRegistry } from "@/registry/bases/base/registry"
|
||||
import { registry as radixRegistry } from "@/registry/bases/radix/registry"
|
||||
|
||||
export async function getAllBlockIds(
|
||||
types: z.infer<typeof registryItemSchema>["type"][] = [
|
||||
"registry:block",
|
||||
"registry:internal",
|
||||
],
|
||||
categories: string[] = []
|
||||
): Promise<string[]> {
|
||||
const blocks = await getAllBlocks(types, categories)
|
||||
|
||||
return blocks.map((block) => block.name)
|
||||
}
|
||||
|
||||
export async function getAllBlocks(
|
||||
types: z.infer<typeof registryItemSchema>["type"][] = [
|
||||
"registry:block",
|
||||
"registry:internal",
|
||||
],
|
||||
categories: string[] = []
|
||||
) {
|
||||
const { Index: StylesIndex } = await import("@/registry/__index__")
|
||||
|
||||
const allBlocks = new Map<string, z.infer<typeof registryItemSchema>>()
|
||||
|
||||
const baseRegistries = [baseRegistry, radixRegistry]
|
||||
.map((registry) => registrySchema.safeParse(registry))
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.data)
|
||||
|
||||
for (const registry of baseRegistries) {
|
||||
for (const item of registry.items) {
|
||||
allBlocks.set(`${item.type}:${item.name}`, item)
|
||||
}
|
||||
}
|
||||
|
||||
for (const style in StylesIndex) {
|
||||
const styleIndex = StylesIndex[style]
|
||||
if (typeof styleIndex !== "object" || styleIndex === null) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const itemName in styleIndex) {
|
||||
const item = styleIndex[itemName]
|
||||
allBlocks.set(`${item.type}:${item.name}`, item)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each block.
|
||||
const validatedBlocks = Array.from(allBlocks.values())
|
||||
.map((block) => {
|
||||
const result = registryItemSchema.safeParse(block)
|
||||
return result.success ? result.data : null
|
||||
})
|
||||
.filter(
|
||||
(block): block is z.infer<typeof registryItemSchema> => block !== null
|
||||
)
|
||||
|
||||
return validatedBlocks.filter(
|
||||
(block) =>
|
||||
types.includes(block.type) &&
|
||||
(categories.length === 0 ||
|
||||
block.categories?.some((category) => categories.includes(category))) &&
|
||||
!block.name.startsWith("chart-")
|
||||
)
|
||||
}
|
||||
27
apps/v4/lib/categories.ts
Normal file
27
apps/v4/lib/categories.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const registryCategories = [
|
||||
{
|
||||
name: "Sidebar",
|
||||
slug: "sidebar",
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: "Dashboard",
|
||||
slug: "dashboard",
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
name: "Authentication",
|
||||
slug: "authentication",
|
||||
hidden: true,
|
||||
},
|
||||
{
|
||||
name: "Login",
|
||||
slug: "login",
|
||||
hidden: false,
|
||||
},
|
||||
{
|
||||
name: "Signup",
|
||||
slug: "signup",
|
||||
hidden: false,
|
||||
},
|
||||
]
|
||||
50
apps/v4/lib/changelog.ts
Normal file
50
apps/v4/lib/changelog.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import fm from "front-matter"
|
||||
|
||||
import { source } from "@/lib/source"
|
||||
|
||||
export type ChangelogPageData = {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type ChangelogPage = ReturnType<typeof source.getPages>[number] & {
|
||||
date: Date | null
|
||||
}
|
||||
|
||||
// Reads the date from the frontmatter of a changelog file.
|
||||
export function getDateFromFile(slugs: string[]) {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"content/docs",
|
||||
...slugs.slice(0, -1),
|
||||
`${slugs[slugs.length - 1]}.mdx`
|
||||
)
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8")
|
||||
const { attributes } = fm<{ date?: string | Date }>(content)
|
||||
if (attributes.date) {
|
||||
return new Date(attributes.date)
|
||||
}
|
||||
} catch {
|
||||
// File not found or parse error.
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Gets all changelog pages sorted by date descending.
|
||||
export function getChangelogPages() {
|
||||
return source
|
||||
.getPages()
|
||||
.filter((page) => page.slugs[0] === "changelog" && page.slugs.length > 1)
|
||||
.map((page) => ({
|
||||
...page,
|
||||
date: getDateFromFile(page.slugs),
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const dateA = a.date?.getTime() ?? 0
|
||||
const dateB = b.date?.getTime() ?? 0
|
||||
return dateB - dateA
|
||||
})
|
||||
}
|
||||
96
apps/v4/lib/colors.ts
Normal file
96
apps/v4/lib/colors.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { colors } from "@/registry/_legacy-colors"
|
||||
|
||||
const colorSchema = z.object({
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
scale: z.number(),
|
||||
className: z.string(),
|
||||
hex: z.string(),
|
||||
rgb: z.string(),
|
||||
hsl: z.string(),
|
||||
foreground: z.string(),
|
||||
oklch: z.string(),
|
||||
var: z.string(),
|
||||
})
|
||||
|
||||
const colorPaletteSchema = z.object({
|
||||
name: z.string(),
|
||||
colors: z.array(colorSchema),
|
||||
})
|
||||
|
||||
export type ColorPalette = z.infer<typeof colorPaletteSchema>
|
||||
|
||||
export function getColorFormat(color: Color) {
|
||||
return {
|
||||
className: `bg-${color.name}-100`,
|
||||
hex: color.hex,
|
||||
rgb: color.rgb,
|
||||
hsl: color.hsl,
|
||||
oklch: color.oklch,
|
||||
var: `--color-${color.name}-${color.scale}`,
|
||||
}
|
||||
}
|
||||
|
||||
export type ColorFormat = keyof ReturnType<typeof getColorFormat>
|
||||
|
||||
export function getColors() {
|
||||
const tailwindColors = colorPaletteSchema.array().parse(
|
||||
Object.entries(colors)
|
||||
.map(([name, color]) => {
|
||||
if (!Array.isArray(color)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
colors: color.map((color) => {
|
||||
const rgb = color.rgb.replace(
|
||||
/^rgb\((\d+),(\d+),(\d+)\)$/,
|
||||
"$1 $2 $3"
|
||||
)
|
||||
|
||||
return {
|
||||
...color,
|
||||
name,
|
||||
id: `${name}-${color.scale}`,
|
||||
className: `${name}-${color.scale}`,
|
||||
var: `--color-${name}-${color.scale}`,
|
||||
rgb,
|
||||
hsl: color.hsl.replace(
|
||||
/^hsl\(([\d.]+),([\d.]+%),([\d.]+%)\)$/,
|
||||
"$1 $2 $3"
|
||||
),
|
||||
oklch: `oklch(${color.oklch.replace(
|
||||
/^oklch\(([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\)$/,
|
||||
"$1 $2 $3"
|
||||
)})`,
|
||||
foreground: getForegroundFromBackground(rgb),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
return tailwindColors
|
||||
}
|
||||
|
||||
export type Color = ReturnType<typeof getColors>[number]["colors"][number]
|
||||
|
||||
function getForegroundFromBackground(rgb: string) {
|
||||
const [r, g, b] = rgb.split(" ").map(Number)
|
||||
|
||||
function toLinear(number: number): number {
|
||||
const base = number / 255
|
||||
return base <= 0.04045
|
||||
? base / 12.92
|
||||
: Math.pow((base + 0.055) / 1.055, 2.4)
|
||||
}
|
||||
|
||||
const luminance =
|
||||
0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b)
|
||||
|
||||
return luminance > 0.179 ? "#000" : "#fff"
|
||||
}
|
||||
65
apps/v4/lib/components.ts
Normal file
65
apps/v4/lib/components.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// All available UI components.
|
||||
export const UI_COMPONENTS = [
|
||||
"accordion",
|
||||
"alert",
|
||||
"alert-dialog",
|
||||
"aspect-ratio",
|
||||
"avatar",
|
||||
"badge",
|
||||
"breadcrumb",
|
||||
"button",
|
||||
"button-group",
|
||||
"calendar",
|
||||
"card",
|
||||
"carousel",
|
||||
"chart",
|
||||
"checkbox",
|
||||
"collapsible",
|
||||
"combobox",
|
||||
"command",
|
||||
"context-menu",
|
||||
"data-table",
|
||||
"date-picker",
|
||||
"dialog",
|
||||
"direction",
|
||||
"drawer",
|
||||
"dropdown-menu",
|
||||
"empty",
|
||||
"field",
|
||||
"form",
|
||||
"hover-card",
|
||||
"input",
|
||||
"input-group",
|
||||
"input-otp",
|
||||
"item",
|
||||
"kbd",
|
||||
"label",
|
||||
"menubar",
|
||||
"native-select",
|
||||
"navigation-menu",
|
||||
"pagination",
|
||||
"popover",
|
||||
"progress",
|
||||
"radio-group",
|
||||
"resizable",
|
||||
"scroll-area",
|
||||
"select",
|
||||
"separator",
|
||||
"sheet",
|
||||
"sidebar",
|
||||
"skeleton",
|
||||
"slider",
|
||||
"sonner",
|
||||
"spinner",
|
||||
"switch",
|
||||
"table",
|
||||
"tabs",
|
||||
"textarea",
|
||||
"toast",
|
||||
"toggle",
|
||||
"toggle-group",
|
||||
"tooltip",
|
||||
"typography",
|
||||
] as const
|
||||
|
||||
export type UIComponent = (typeof UI_COMPONENTS)[number]
|
||||
50
apps/v4/lib/config.ts
Normal file
50
apps/v4/lib/config.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export const siteConfig = {
|
||||
name: "shadcn/ui",
|
||||
url: "https://ui.shadcn.com",
|
||||
ogImage: "https://ui.shadcn.com/og.jpg",
|
||||
description:
|
||||
"Composable, accessible components with thoughtful defaults. Build your own component library with code you can customize, extend, and make your own.",
|
||||
links: {
|
||||
twitter: "https://twitter.com/shadcn",
|
||||
github: "https://github.com/shadcn-ui/ui",
|
||||
},
|
||||
navItems: [
|
||||
{
|
||||
href: "/",
|
||||
label: "Home",
|
||||
},
|
||||
{
|
||||
href: "/docs/installation",
|
||||
label: "Docs",
|
||||
},
|
||||
{
|
||||
href: "/docs/components",
|
||||
label: "Components",
|
||||
},
|
||||
{
|
||||
href: "/blocks",
|
||||
label: "Blocks",
|
||||
},
|
||||
{
|
||||
href: "/charts/area",
|
||||
label: "Charts",
|
||||
},
|
||||
{
|
||||
href: "/docs/directory",
|
||||
label: "Directory",
|
||||
},
|
||||
{
|
||||
href: "/typeset",
|
||||
label: "Typeset",
|
||||
},
|
||||
{
|
||||
href: "/create",
|
||||
label: "Create",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const META_THEME_COLORS = {
|
||||
light: "#ffffff",
|
||||
dark: "#09090b",
|
||||
}
|
||||
66
apps/v4/lib/docs-sidebar-scroll.ts
Normal file
66
apps/v4/lib/docs-sidebar-scroll.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export const DOCS_SIDEBAR_SCROLL_STORAGE_KEY = "docs-sidebar-scroll"
|
||||
|
||||
export const DOCS_SIDEBAR_SCROLL_RESTORE_SCRIPT = `(function () {
|
||||
if (location.pathname !== "/docs" && !location.pathname.startsWith("/docs/")) return
|
||||
|
||||
var stored = null
|
||||
try {
|
||||
stored = JSON.parse(sessionStorage.getItem("${DOCS_SIDEBAR_SCROLL_STORAGE_KEY}"))
|
||||
} catch (e) {}
|
||||
|
||||
function getActiveItem(container) {
|
||||
var items = container.querySelectorAll('[data-active="true"]')
|
||||
var active = null
|
||||
var activePathLength = -1
|
||||
var activeDistance = Infinity
|
||||
var containerCenter = container.getBoundingClientRect().top + container.clientHeight / 2
|
||||
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
var link = items[i].querySelector('a[href]')
|
||||
var href = items[i].getAttribute('href') || (link && link.getAttribute('href'))
|
||||
var pathLength = href ? href.length : 0
|
||||
var itemRect = items[i].getBoundingClientRect()
|
||||
var distance = Math.abs(itemRect.top + itemRect.height / 2 - containerCenter)
|
||||
|
||||
if (pathLength > activePathLength || (pathLength === activePathLength && distance < activeDistance)) {
|
||||
active = items[i]
|
||||
activePathLength = pathLength
|
||||
activeDistance = distance
|
||||
}
|
||||
}
|
||||
|
||||
return active
|
||||
}
|
||||
|
||||
function restoreScroll() {
|
||||
var container = document.querySelector('[data-docs-sidebar-content]')
|
||||
if (!container || !container.clientHeight) return
|
||||
|
||||
if (stored && stored.pathname === location.pathname) {
|
||||
container.scrollTop = stored.scrollTop
|
||||
return
|
||||
}
|
||||
|
||||
var active = getActiveItem(container)
|
||||
if (!active) return
|
||||
|
||||
var containerRect = container.getBoundingClientRect()
|
||||
var activeRect = active.getBoundingClientRect()
|
||||
if (activeRect.top >= containerRect.top && activeRect.bottom <= containerRect.bottom) return
|
||||
|
||||
container.scrollTop += activeRect.top - containerRect.top - (container.clientHeight - activeRect.height) / 2
|
||||
}
|
||||
|
||||
var observer = new MutationObserver(restoreScroll)
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true })
|
||||
restoreScroll()
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
restoreScroll()
|
||||
observer.disconnect()
|
||||
}, { once: true })
|
||||
} else {
|
||||
observer.disconnect()
|
||||
}
|
||||
})()`
|
||||
12
apps/v4/lib/docs.ts
Normal file
12
apps/v4/lib/docs.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const PAGES_NEW = [
|
||||
"/docs/changelog",
|
||||
"/docs/changelog/2026-08-questionnaire",
|
||||
"/docs/registry/dynamic-search",
|
||||
"/docs/components/radix/questionnaire",
|
||||
"/docs/components/base/questionnaire",
|
||||
"/docs/components/aria/questionnaire",
|
||||
"/docs/react/questionnaire",
|
||||
"/docs/registry/health",
|
||||
]
|
||||
|
||||
export const PAGES_UPDATED = []
|
||||
41
apps/v4/lib/events.ts
Normal file
41
apps/v4/lib/events.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import va from "@vercel/analytics"
|
||||
import { z } from "zod"
|
||||
|
||||
const eventSchema = z.object({
|
||||
name: z.enum([
|
||||
"copy_npm_command",
|
||||
"copy_usage_import_code",
|
||||
"copy_usage_code",
|
||||
"copy_primitive_code",
|
||||
"copy_theme_code",
|
||||
"copy_block_code",
|
||||
"copy_chunk_code",
|
||||
"enable_lift_mode",
|
||||
"copy_chart_code",
|
||||
"copy_chart_theme",
|
||||
"copy_chart_data",
|
||||
"copy_color",
|
||||
"set_layout",
|
||||
"search_query",
|
||||
"create_app",
|
||||
"copy_create_share_url",
|
||||
"copy_registry_add_command",
|
||||
"copy_preset_command",
|
||||
"copy_apply_command",
|
||||
"copy_typeset_css",
|
||||
"copy_typeset_prompt",
|
||||
]),
|
||||
// declare type AllowedPropertyValues = string | number | boolean | null
|
||||
properties: z
|
||||
.record(z.union([z.string(), z.number(), z.boolean(), z.null()]))
|
||||
.optional(),
|
||||
})
|
||||
|
||||
export type Event = z.infer<typeof eventSchema>
|
||||
|
||||
export function trackEvent(input: Event): void {
|
||||
const event = eventSchema.parse(input)
|
||||
if (event) {
|
||||
va.track(event.name, event.properties)
|
||||
}
|
||||
}
|
||||
1
apps/v4/lib/flags.ts
Normal file
1
apps/v4/lib/flags.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const showMcpDocs = true
|
||||
331
apps/v4/lib/font-definitions.ts
Normal file
331
apps/v4/lib/font-definitions.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
export type FontDefinition = {
|
||||
name: string
|
||||
title: string
|
||||
type: "sans" | "mono" | "serif"
|
||||
family: string
|
||||
registryVariable: "--font-sans" | "--font-mono" | "--font-serif"
|
||||
previewVariable: string
|
||||
provider: "google"
|
||||
import: string
|
||||
dependency: string
|
||||
subsets: readonly string[]
|
||||
weight?: readonly string[]
|
||||
}
|
||||
|
||||
export const FONT_DEFINITIONS = [
|
||||
{
|
||||
name: "geist",
|
||||
title: "Geist",
|
||||
type: "sans",
|
||||
family: "'Geist Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-geist-sans",
|
||||
provider: "google",
|
||||
import: "Geist",
|
||||
dependency: "@fontsource-variable/geist",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "inter",
|
||||
title: "Inter",
|
||||
type: "sans",
|
||||
family: "'Inter Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-inter",
|
||||
provider: "google",
|
||||
import: "Inter",
|
||||
dependency: "@fontsource-variable/inter",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "noto-sans",
|
||||
title: "Noto Sans",
|
||||
type: "sans",
|
||||
family: "'Noto Sans Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-noto-sans",
|
||||
provider: "google",
|
||||
import: "Noto_Sans",
|
||||
dependency: "@fontsource-variable/noto-sans",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "nunito-sans",
|
||||
title: "Nunito Sans",
|
||||
type: "sans",
|
||||
family: "'Nunito Sans Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-nunito-sans",
|
||||
provider: "google",
|
||||
import: "Nunito_Sans",
|
||||
dependency: "@fontsource-variable/nunito-sans",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "figtree",
|
||||
title: "Figtree",
|
||||
type: "sans",
|
||||
family: "'Figtree Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-figtree",
|
||||
provider: "google",
|
||||
import: "Figtree",
|
||||
dependency: "@fontsource-variable/figtree",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "roboto",
|
||||
title: "Roboto",
|
||||
type: "sans",
|
||||
family: "'Roboto Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-roboto",
|
||||
provider: "google",
|
||||
import: "Roboto",
|
||||
dependency: "@fontsource-variable/roboto",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "raleway",
|
||||
title: "Raleway",
|
||||
type: "sans",
|
||||
family: "'Raleway Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-raleway",
|
||||
provider: "google",
|
||||
import: "Raleway",
|
||||
dependency: "@fontsource-variable/raleway",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "dm-sans",
|
||||
title: "DM Sans",
|
||||
type: "sans",
|
||||
family: "'DM Sans Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-dm-sans",
|
||||
provider: "google",
|
||||
import: "DM_Sans",
|
||||
dependency: "@fontsource-variable/dm-sans",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "public-sans",
|
||||
title: "Public Sans",
|
||||
type: "sans",
|
||||
family: "'Public Sans Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-public-sans",
|
||||
provider: "google",
|
||||
import: "Public_Sans",
|
||||
dependency: "@fontsource-variable/public-sans",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "outfit",
|
||||
title: "Outfit",
|
||||
type: "sans",
|
||||
family: "'Outfit Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-outfit",
|
||||
provider: "google",
|
||||
import: "Outfit",
|
||||
dependency: "@fontsource-variable/outfit",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "oxanium",
|
||||
title: "Oxanium",
|
||||
type: "sans",
|
||||
family: "'Oxanium Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-oxanium",
|
||||
provider: "google",
|
||||
import: "Oxanium",
|
||||
dependency: "@fontsource-variable/oxanium",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "manrope",
|
||||
title: "Manrope",
|
||||
type: "sans",
|
||||
family: "'Manrope Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-manrope",
|
||||
provider: "google",
|
||||
import: "Manrope",
|
||||
dependency: "@fontsource-variable/manrope",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "space-grotesk",
|
||||
title: "Space Grotesk",
|
||||
type: "sans",
|
||||
family: "'Space Grotesk Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-space-grotesk",
|
||||
provider: "google",
|
||||
import: "Space_Grotesk",
|
||||
dependency: "@fontsource-variable/space-grotesk",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "montserrat",
|
||||
title: "Montserrat",
|
||||
type: "sans",
|
||||
family: "'Montserrat Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-montserrat",
|
||||
provider: "google",
|
||||
import: "Montserrat",
|
||||
dependency: "@fontsource-variable/montserrat",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "ibm-plex-sans",
|
||||
title: "IBM Plex Sans",
|
||||
type: "sans",
|
||||
family: "'IBM Plex Sans Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-ibm-plex-sans",
|
||||
provider: "google",
|
||||
import: "IBM_Plex_Sans",
|
||||
dependency: "@fontsource-variable/ibm-plex-sans",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "source-sans-3",
|
||||
title: "Source Sans 3",
|
||||
type: "sans",
|
||||
family: "'Source Sans 3 Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-source-sans-3",
|
||||
provider: "google",
|
||||
import: "Source_Sans_3",
|
||||
dependency: "@fontsource-variable/source-sans-3",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "instrument-sans",
|
||||
title: "Instrument Sans",
|
||||
type: "sans",
|
||||
family: "'Instrument Sans Variable', sans-serif",
|
||||
registryVariable: "--font-sans",
|
||||
previewVariable: "--font-instrument-sans",
|
||||
provider: "google",
|
||||
import: "Instrument_Sans",
|
||||
dependency: "@fontsource-variable/instrument-sans",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "jetbrains-mono",
|
||||
title: "JetBrains Mono",
|
||||
type: "mono",
|
||||
family: "'JetBrains Mono Variable', monospace",
|
||||
registryVariable: "--font-mono",
|
||||
previewVariable: "--font-jetbrains-mono",
|
||||
provider: "google",
|
||||
import: "JetBrains_Mono",
|
||||
dependency: "@fontsource-variable/jetbrains-mono",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "geist-mono",
|
||||
title: "Geist Mono",
|
||||
type: "mono",
|
||||
family: "'Geist Mono Variable', monospace",
|
||||
registryVariable: "--font-mono",
|
||||
previewVariable: "--font-geist-mono",
|
||||
provider: "google",
|
||||
import: "Geist_Mono",
|
||||
dependency: "@fontsource-variable/geist-mono",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "noto-serif",
|
||||
title: "Noto Serif",
|
||||
type: "serif",
|
||||
family: "'Noto Serif Variable', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-noto-serif",
|
||||
provider: "google",
|
||||
import: "Noto_Serif",
|
||||
dependency: "@fontsource-variable/noto-serif",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "roboto-slab",
|
||||
title: "Roboto Slab",
|
||||
type: "serif",
|
||||
family: "'Roboto Slab Variable', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-roboto-slab",
|
||||
provider: "google",
|
||||
import: "Roboto_Slab",
|
||||
dependency: "@fontsource-variable/roboto-slab",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "merriweather",
|
||||
title: "Merriweather",
|
||||
type: "serif",
|
||||
family: "'Merriweather Variable', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-merriweather",
|
||||
provider: "google",
|
||||
import: "Merriweather",
|
||||
dependency: "@fontsource-variable/merriweather",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "lora",
|
||||
title: "Lora",
|
||||
type: "serif",
|
||||
family: "'Lora Variable', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-lora",
|
||||
provider: "google",
|
||||
import: "Lora",
|
||||
dependency: "@fontsource-variable/lora",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "playfair-display",
|
||||
title: "Playfair Display",
|
||||
type: "serif",
|
||||
family: "'Playfair Display Variable', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-playfair-display",
|
||||
provider: "google",
|
||||
import: "Playfair_Display",
|
||||
dependency: "@fontsource-variable/playfair-display",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "eb-garamond",
|
||||
title: "EB Garamond",
|
||||
type: "serif",
|
||||
family: "'EB Garamond Variable', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-eb-garamond",
|
||||
provider: "google",
|
||||
import: "EB_Garamond",
|
||||
dependency: "@fontsource-variable/eb-garamond",
|
||||
subsets: ["latin"],
|
||||
},
|
||||
{
|
||||
name: "instrument-serif",
|
||||
title: "Instrument Serif",
|
||||
type: "serif",
|
||||
family: "'Instrument Serif', serif",
|
||||
registryVariable: "--font-serif",
|
||||
previewVariable: "--font-instrument-serif",
|
||||
provider: "google",
|
||||
import: "Instrument_Serif",
|
||||
dependency: "@fontsource/instrument-serif",
|
||||
subsets: ["latin"],
|
||||
weight: ["400"],
|
||||
},
|
||||
] as const satisfies readonly FontDefinition[]
|
||||
|
||||
export type FontName = (typeof FONT_DEFINITIONS)[number]["name"]
|
||||
41
apps/v4/lib/fonts.ts
Normal file
41
apps/v4/lib/fonts.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Geist_Mono as FontMono,
|
||||
Noto_Sans_Arabic as FontNotoSansArabic,
|
||||
Noto_Sans_Hebrew as FontNotoSansHebrew,
|
||||
Geist as FontSans,
|
||||
} from "next/font/google"
|
||||
import { cn } from "cn"
|
||||
|
||||
const fontSans = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
})
|
||||
|
||||
const fontHeading = FontSans({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-heading",
|
||||
})
|
||||
|
||||
const fontMono = FontMono({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-mono",
|
||||
weight: ["400"],
|
||||
})
|
||||
|
||||
const fontNotoSansArabic = FontNotoSansArabic({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-ar",
|
||||
})
|
||||
|
||||
const fontNotoSansHebrew = FontNotoSansHebrew({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-he",
|
||||
})
|
||||
|
||||
export const fontVariables = cn(
|
||||
fontSans.variable,
|
||||
fontHeading.variable,
|
||||
fontMono.variable,
|
||||
fontNotoSansArabic.variable,
|
||||
fontNotoSansHebrew.variable
|
||||
)
|
||||
142
apps/v4/lib/format-code.ts
Normal file
142
apps/v4/lib/format-code.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { promises as fsPromises } from "fs"
|
||||
import path from "path"
|
||||
import {
|
||||
createStyleMap,
|
||||
transformIcons,
|
||||
transformMenu,
|
||||
transformRender,
|
||||
transformStyle,
|
||||
} from "shadcn/utils"
|
||||
import { Project, ScriptKind, type SourceFile } from "ts-morph"
|
||||
|
||||
import { BASES } from "@/registry/bases"
|
||||
|
||||
function getStyleFromStyleName(styleName: string) {
|
||||
const parts = styleName.split("-")
|
||||
return parts.length > 1 ? parts.slice(1).join("-") : styleName
|
||||
}
|
||||
|
||||
function buildDisplayConfig(styleName: string) {
|
||||
return {
|
||||
$schema: "https://ui.shadcn.com/schema.json",
|
||||
style: styleName,
|
||||
rsc: true,
|
||||
tsx: true,
|
||||
tailwind: {
|
||||
config: "",
|
||||
css: "",
|
||||
baseColor: "neutral",
|
||||
cssVariables: true,
|
||||
prefix: "",
|
||||
},
|
||||
iconLibrary: "lucide",
|
||||
aliases: {
|
||||
components: "@/components",
|
||||
utils: "@/lib/utils",
|
||||
ui: "@/components/ui",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
},
|
||||
resolvedPaths: {
|
||||
cwd: "/",
|
||||
tailwindConfig: "",
|
||||
tailwindCss: "",
|
||||
utils: "@/lib/utils",
|
||||
components: "@/components",
|
||||
lib: "@/lib",
|
||||
hooks: "@/hooks",
|
||||
ui: "@/components/ui",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type DisplayTransformer = (opts: {
|
||||
filename: string
|
||||
raw: string
|
||||
sourceFile: SourceFile
|
||||
config: ReturnType<typeof buildDisplayConfig>
|
||||
}) => Promise<unknown>
|
||||
|
||||
const styleMapCache = new Map<string, Record<string, string>>()
|
||||
|
||||
async function getStyleMap(styleName: string) {
|
||||
const style = getStyleFromStyleName(styleName)
|
||||
|
||||
if (styleMapCache.has(style)) {
|
||||
return styleMapCache.get(style)!
|
||||
}
|
||||
|
||||
try {
|
||||
const cssPath = path.join(
|
||||
process.cwd(),
|
||||
`registry/styles/style-${style}.css`
|
||||
)
|
||||
const css = await fsPromises.readFile(cssPath, "utf-8")
|
||||
const styleMap = createStyleMap(css)
|
||||
styleMapCache.set(style, styleMap)
|
||||
return styleMap
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function formatCode(code: string, styleName: string) {
|
||||
code = code.replaceAll(`@/registry/${styleName}/`, "@/components/")
|
||||
|
||||
// Always rewrite the legacy tree so src-based sources render clean imports
|
||||
// regardless of the styleName in play.
|
||||
code = code.replaceAll("@/registry/new-york-v4/", "@/components/")
|
||||
|
||||
for (const base of BASES) {
|
||||
code = code.replaceAll(`@/registry/bases/${base.name}/`, "@/components/")
|
||||
code = code.replaceAll(
|
||||
`@/examples/${base.name}/ui-rtl/`,
|
||||
"@/components/ui/"
|
||||
)
|
||||
code = code.replaceAll(`@/examples/${base.name}/ui/`, "@/components/ui/")
|
||||
code = code.replaceAll(`@/examples/${base.name}/lib/`, "@/lib/")
|
||||
code = code.replaceAll(`@/examples/${base.name}/hooks/`, "@/hooks/")
|
||||
}
|
||||
|
||||
code = code.replace(
|
||||
/@\/styles\/([\w-]+)\/(ui-rtl|ui)\/([\w-]+)/g,
|
||||
(match, _styleName, type, component) => {
|
||||
if (type === "ui" || type === "ui-rtl") {
|
||||
return `@/components/ui/${component}`
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
)
|
||||
|
||||
code = code.replaceAll("export default", "export")
|
||||
|
||||
try {
|
||||
const styleMap = await getStyleMap(styleName)
|
||||
const transformed = await transformStyle(code, { styleMap })
|
||||
const config = buildDisplayConfig(styleName)
|
||||
const project = new Project({ compilerOptions: {} })
|
||||
const sourceFile = project.createSourceFile("component.tsx", transformed, {
|
||||
scriptKind: ScriptKind.TSX,
|
||||
})
|
||||
|
||||
const transformers: DisplayTransformer[] = [
|
||||
transformIcons as DisplayTransformer,
|
||||
transformMenu as DisplayTransformer,
|
||||
transformRender as DisplayTransformer,
|
||||
]
|
||||
for (const transformer of transformers) {
|
||||
await transformer({
|
||||
filename: "component.tsx",
|
||||
raw: transformed,
|
||||
sourceFile,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
return sourceFile.getText()
|
||||
} catch (error) {
|
||||
console.error("Transform failed:", error)
|
||||
return code
|
||||
}
|
||||
}
|
||||
98
apps/v4/lib/highlight-code.ts
Normal file
98
apps/v4/lib/highlight-code.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createHash } from "crypto"
|
||||
import { LRUCache } from "lru-cache"
|
||||
import { codeToHtml } from "shiki"
|
||||
import type { ShikiTransformer } from "shiki"
|
||||
|
||||
// LRU cache for cross-request caching of highlighted code.
|
||||
// Shiki highlighting is CPU-intensive and deterministic, so caching is safe.
|
||||
const highlightCache = new LRUCache<string, string>({
|
||||
max: 500,
|
||||
ttl: 1000 * 60 * 60, // 1 hour.
|
||||
})
|
||||
|
||||
export const transformers = [
|
||||
{
|
||||
code(node) {
|
||||
if (node.tagName === "code") {
|
||||
const raw = this.source
|
||||
node.properties["__raw__"] = raw
|
||||
|
||||
if (raw.startsWith("npm install")) {
|
||||
node.properties["__npm__"] = raw
|
||||
node.properties["__yarn__"] = raw.replace("npm install", "yarn add")
|
||||
node.properties["__pnpm__"] = raw.replace("npm install", "pnpm add")
|
||||
node.properties["__bun__"] = raw.replace("npm install", "bun add")
|
||||
} else if (raw.startsWith("npx create-")) {
|
||||
node.properties["__npm__"] = raw
|
||||
node.properties["__yarn__"] = raw.replace(
|
||||
"npx create-",
|
||||
"yarn create "
|
||||
)
|
||||
node.properties["__pnpm__"] = raw.replace(
|
||||
"npx create-",
|
||||
"pnpm create "
|
||||
)
|
||||
node.properties["__bun__"] = raw.replace("npx", "bunx --bun")
|
||||
} else if (raw.startsWith("npm create")) {
|
||||
// npm create.
|
||||
node.properties["__npm__"] = raw
|
||||
node.properties["__yarn__"] = raw.replace("npm create", "yarn create")
|
||||
node.properties["__pnpm__"] = raw.replace("npm create", "pnpm create")
|
||||
node.properties["__bun__"] = raw.replace("npm create", "bun create")
|
||||
} else if (raw.startsWith("npx")) {
|
||||
// npx.
|
||||
node.properties["__npm__"] = raw
|
||||
node.properties["__yarn__"] = raw.replace("npx", "yarn dlx")
|
||||
node.properties["__pnpm__"] = raw.replace("npx", "pnpm dlx")
|
||||
node.properties["__bun__"] = raw.replace("npx", "bunx --bun")
|
||||
} else if (raw.startsWith("npm run")) {
|
||||
// npm run.
|
||||
node.properties["__npm__"] = raw
|
||||
node.properties["__yarn__"] = raw.replace("npm run", "yarn")
|
||||
node.properties["__pnpm__"] = raw.replace("npm run", "pnpm")
|
||||
node.properties["__bun__"] = raw.replace("npm run", "bun")
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
] as ShikiTransformer[]
|
||||
|
||||
export async function highlightCode(code: string, language: string = "tsx") {
|
||||
// Create cache key from code content and language.
|
||||
const cacheKey = createHash("sha256")
|
||||
.update(`${language}:${code}`)
|
||||
.digest("hex")
|
||||
|
||||
// Check cache first.
|
||||
const cached = highlightCache.get(cacheKey)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const html = await codeToHtml(code, {
|
||||
lang: language,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
transformers: [
|
||||
{
|
||||
pre(node) {
|
||||
node.properties["class"] =
|
||||
"no-scrollbar min-w-0 overflow-x-auto overflow-y-auto overscroll-x-contain overscroll-y-auto px-4 py-3.5 outline-none has-[[data-highlighted-line]]:px-0 has-[[data-line-numbers]]:px-0 has-[[data-slot=tabs]]:p-0 !bg-transparent"
|
||||
},
|
||||
code(node) {
|
||||
node.properties["data-line-numbers"] = ""
|
||||
},
|
||||
line(node) {
|
||||
node.properties["data-line"] = ""
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Cache the result.
|
||||
highlightCache.set(cacheKey, html)
|
||||
|
||||
return html
|
||||
}
|
||||
143
apps/v4/lib/llm.ts
Normal file
143
apps/v4/lib/llm.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import fs from "fs"
|
||||
import { ExamplesIndex } from "@/examples/__index__"
|
||||
|
||||
import { PAGES_NEW } from "@/lib/docs"
|
||||
import { getPagesFromFolder, type PageTreeFolder } from "@/lib/page-tree"
|
||||
import { source } from "@/lib/source"
|
||||
import { absoluteUrl } from "@/lib/utils"
|
||||
import { Index as StylesIndex } from "@/registry/__index__"
|
||||
import { type Style } from "@/registry/_legacy-styles"
|
||||
import { BASES } from "@/registry/bases"
|
||||
import { Index as BasesIndex } from "@/registry/bases/__index__"
|
||||
|
||||
function getBaseForStyle(styleName: string) {
|
||||
for (const base of BASES) {
|
||||
if (styleName.startsWith(`${base.name}-`)) {
|
||||
return base.name
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getDemoFilePath(name: string, styleName: string) {
|
||||
const base = getBaseForStyle(styleName)
|
||||
const demo =
|
||||
ExamplesIndex[styleName]?.[name] ??
|
||||
(base ? ExamplesIndex[base]?.[name] : undefined)
|
||||
if (!demo) {
|
||||
return null
|
||||
}
|
||||
return demo.filePath
|
||||
}
|
||||
|
||||
function getRegistryEntry(name: string, styleName: string) {
|
||||
const base = getBaseForStyle(styleName)
|
||||
return (
|
||||
StylesIndex[styleName]?.[name] ??
|
||||
(base ? BasesIndex[base]?.[name] : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function getComponentsList(variant: "all" | "new") {
|
||||
const componentsFolder = source.pageTree.children.find(
|
||||
(page) => page.$id === "components"
|
||||
)
|
||||
|
||||
if (componentsFolder?.type !== "folder") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return getPagesFromFolder(componentsFolder as PageTreeFolder, "base")
|
||||
.filter(
|
||||
(component) => variant === "all" || PAGES_NEW.includes(component.url)
|
||||
)
|
||||
.map((component) => {
|
||||
const slug = component.url.replace(/^\/docs\//, "").split("/")
|
||||
const description = source.getPage(slug)?.data.description?.trim()
|
||||
const url = absoluteUrl(component.url.replace("/base/", "/"))
|
||||
return `- [${component.name}](${url})${
|
||||
description ? `: ${description}` : ""
|
||||
}`
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export function replaceComponentsList(content: string) {
|
||||
return content
|
||||
.replace(
|
||||
/<ComponentsList\s+variant=["']new["']\s*\/>/g,
|
||||
getComponentsList("new")
|
||||
)
|
||||
.replace(/<ComponentsList\s*\/>/g, getComponentsList("all"))
|
||||
}
|
||||
|
||||
export function processMdxForLLMs(content: string, style: Style["name"]) {
|
||||
content = replaceComponentsList(content)
|
||||
|
||||
const componentPreviewRegex =
|
||||
/<ComponentPreview[\s\S]*?name="([^"]+)"[\s\S]*?\/>/g
|
||||
|
||||
return content.replace(componentPreviewRegex, (match, name) => {
|
||||
try {
|
||||
// Try to extract styleName from the match.
|
||||
const styleNameMatch = match.match(/styleName="([^"]+)"/)
|
||||
const effectiveStyle = styleNameMatch ? styleNameMatch[1] : style
|
||||
|
||||
let src = getDemoFilePath(name, effectiveStyle)
|
||||
|
||||
if (!src) {
|
||||
const component = getRegistryEntry(name, effectiveStyle)
|
||||
if (!component?.files) {
|
||||
return match
|
||||
}
|
||||
src = component.files[0]?.path
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return match
|
||||
}
|
||||
|
||||
let source = fs.readFileSync(src, "utf8")
|
||||
|
||||
// Replace all base-specific paths.
|
||||
for (const base of BASES) {
|
||||
source = source.replaceAll(
|
||||
`@/registry/bases/${base.name}/`,
|
||||
"@/components/"
|
||||
)
|
||||
source = source.replaceAll(
|
||||
`@/examples/${base.name}/ui-rtl/`,
|
||||
"@/components/ui/"
|
||||
)
|
||||
source = source.replaceAll(
|
||||
`@/examples/${base.name}/ui/`,
|
||||
"@/components/ui/"
|
||||
)
|
||||
source = source.replaceAll(`@/examples/${base.name}/lib/`, "@/lib/")
|
||||
source = source.replaceAll(`@/examples/${base.name}/hooks/`, "@/hooks/")
|
||||
}
|
||||
source = source.replace(
|
||||
/@\/styles\/([\w-]+)\/(ui-rtl|ui)\/([\w-]+)/g,
|
||||
(match, _styleName, type, component) => {
|
||||
if (type === "ui" || type === "ui-rtl") {
|
||||
return `@/components/ui/${component}`
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
)
|
||||
source = source.replaceAll(
|
||||
`@/registry/${effectiveStyle}/`,
|
||||
"@/components/"
|
||||
)
|
||||
source = source.replaceAll("export default", "export")
|
||||
|
||||
return `\`\`\`tsx
|
||||
${source}
|
||||
\`\`\``
|
||||
} catch (error) {
|
||||
console.error(`Error processing ComponentPreview ${name}:`, error)
|
||||
return match
|
||||
}
|
||||
})
|
||||
}
|
||||
137
apps/v4/lib/message-animations.ts
Normal file
137
apps/v4/lib/message-animations.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import type { Variants } from "motion/react"
|
||||
|
||||
const ANIMATIONS = [
|
||||
{
|
||||
id: "fade",
|
||||
name: "Fade",
|
||||
variants: {
|
||||
initial: { opacity: 0 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
transition: { duration: 0.22, ease: "easeOut" },
|
||||
},
|
||||
exit: { opacity: 0, transition: { duration: 0.15 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slide-up",
|
||||
name: "Slide Up",
|
||||
variants: {
|
||||
initial: { opacity: 0, y: 10 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.26, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
exit: { opacity: 0, y: 4, transition: { duration: 0.15 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slide-side",
|
||||
name: "Slide Side",
|
||||
variants: {
|
||||
initial: { opacity: 0, x: 18 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: { duration: 0.28, ease: [0.16, 1, 0.3, 1] },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
x: 8,
|
||||
transition: { duration: 0.15 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "pop",
|
||||
name: "Pop",
|
||||
variants: {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
scale: 0.94,
|
||||
y: 6,
|
||||
originX: 1,
|
||||
originY: 1,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: { type: "spring", stiffness: 500, damping: 34, mass: 0.7 },
|
||||
},
|
||||
exit: { opacity: 0, scale: 0.98, transition: { duration: 0.12 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "spring-bounce",
|
||||
name: "Spring Bounce",
|
||||
variants: {
|
||||
initial: { opacity: 0, y: 12, scale: 0.96 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
type: "spring",
|
||||
stiffness: 520,
|
||||
damping: 30,
|
||||
mass: 0.7,
|
||||
},
|
||||
},
|
||||
exit: { opacity: 0, scale: 0.98, transition: { duration: 0.12 } },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "blur-fade",
|
||||
name: "Blur Fade",
|
||||
variants: {
|
||||
initial: { opacity: 0, filter: "blur(4px)", y: 6 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
transition: { duration: 0.28, ease: "easeOut" },
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
filter: "blur(2px)",
|
||||
transition: { duration: 0.15 },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "scale-fade",
|
||||
name: "Scale Fade",
|
||||
variants: {
|
||||
initial: { opacity: 0, scale: 0.98 },
|
||||
animate: {
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
transition: { duration: 0.24, ease: "easeOut" },
|
||||
},
|
||||
exit: { opacity: 0, scale: 0.99, transition: { duration: 0.12 } },
|
||||
},
|
||||
},
|
||||
] as const satisfies {
|
||||
id: string
|
||||
name: string
|
||||
variants: Variants
|
||||
}[]
|
||||
|
||||
type MessageAnimationPreset = (typeof ANIMATIONS)[number]
|
||||
type MessageAnimationId = MessageAnimationPreset["id"]
|
||||
|
||||
const MESSAGE_ANIMATIONS = ANIMATIONS.reduce(
|
||||
(acc, preset) => {
|
||||
acc[preset.id] = preset
|
||||
return acc
|
||||
},
|
||||
{} as Record<MessageAnimationId, MessageAnimationPreset>
|
||||
)
|
||||
|
||||
export {
|
||||
MESSAGE_ANIMATIONS,
|
||||
type MessageAnimationId,
|
||||
type MessageAnimationPreset,
|
||||
}
|
||||
64
apps/v4/lib/page-tree.ts
Normal file
64
apps/v4/lib/page-tree.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { source } from "@/lib/source"
|
||||
|
||||
export type PageTreeNode = (typeof source.pageTree)["children"][number]
|
||||
export type PageTreeFolder = Extract<PageTreeNode, { type: "folder" }>
|
||||
export type PageTreePage = Extract<PageTreeNode, { type: "page" }>
|
||||
|
||||
// Recursively find all pages in a folder tree.
|
||||
export function getAllPagesFromFolder(folder: PageTreeFolder): PageTreePage[] {
|
||||
const pages: PageTreePage[] = []
|
||||
|
||||
for (const child of folder.children) {
|
||||
if (child.type === "page") {
|
||||
pages.push(child)
|
||||
} else if (child.type === "folder") {
|
||||
pages.push(...getAllPagesFromFolder(child))
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
// Get the pages from a folder, handling nested base folders (radix/base).
|
||||
export function getPagesFromFolder(
|
||||
folder: PageTreeFolder,
|
||||
currentBase: string
|
||||
): PageTreePage[] {
|
||||
// For the components folder, find the base subfolder.
|
||||
if (folder.$id === "components" || folder.name === "Components") {
|
||||
for (const child of folder.children) {
|
||||
if (child.type === "folder") {
|
||||
// Match by $id or by name.
|
||||
const isRadix = child.$id === "radix" || child.name === "Radix UI"
|
||||
const isBase = child.$id === "base" || child.name === "Base UI"
|
||||
const isReactAria = child.$id === "aria" || child.name === "React Aria"
|
||||
|
||||
if (
|
||||
(currentBase === "radix" && isRadix) ||
|
||||
(currentBase === "base" && isBase) ||
|
||||
(currentBase === "aria" && isReactAria)
|
||||
) {
|
||||
return child.children.filter(
|
||||
(c): c is PageTreePage => c.type === "page"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: return all pages from nested folders.
|
||||
return getAllPagesFromFolder(folder).filter(
|
||||
(page) => !page.url.endsWith("/components")
|
||||
)
|
||||
}
|
||||
|
||||
// For other folders, return direct page children.
|
||||
return folder.children.filter(
|
||||
(child): child is PageTreePage => child.type === "page"
|
||||
)
|
||||
}
|
||||
|
||||
// Get current base (radix, base, or aria) from pathname.
|
||||
export function getCurrentBase(pathname: string): string {
|
||||
const baseMatch = pathname.match(/\/docs\/components\/(radix|base|aria)\//)
|
||||
return baseMatch ? baseMatch[1] : "base" // Default to base.
|
||||
}
|
||||
7
apps/v4/lib/read-file.ts
Normal file
7
apps/v4/lib/read-file.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
|
||||
export async function readFileFromRoot(relativePath: string) {
|
||||
const absolutePath = path.join(process.cwd(), relativePath)
|
||||
return fs.readFile(absolutePath, "utf-8")
|
||||
}
|
||||
76
apps/v4/lib/registry-directory.ts
Normal file
76
apps/v4/lib/registry-directory.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { z } from "zod"
|
||||
|
||||
const registryNamespaceSchema = z.string().regex(/^@[a-zA-Z0-9][a-zA-Z0-9_-]*$/)
|
||||
|
||||
const registryDirectoryEntrySchema = z
|
||||
.object({
|
||||
name: registryNamespaceSchema,
|
||||
homepage: z.string().url(),
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((url) => url.includes("{name}"), {
|
||||
message: "URL must include {name} placeholder",
|
||||
}),
|
||||
description: z.string(),
|
||||
author: z.string().optional(),
|
||||
logo: z.string(),
|
||||
})
|
||||
.strict()
|
||||
|
||||
const registryDirectorySchema = z
|
||||
.array(registryDirectoryEntrySchema)
|
||||
.superRefine((entries, context) => {
|
||||
const names = new Set<string>()
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const name = entry.name.toLowerCase()
|
||||
if (names.has(name)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Duplicate registry namespace: ${entry.name}`,
|
||||
path: [index, "name"],
|
||||
})
|
||||
}
|
||||
names.add(name)
|
||||
})
|
||||
})
|
||||
|
||||
const publicRegistryDirectoryEntrySchema = registryDirectoryEntrySchema.pick({
|
||||
name: true,
|
||||
homepage: true,
|
||||
url: true,
|
||||
description: true,
|
||||
})
|
||||
|
||||
const publicRegistryDirectorySchema = z.array(
|
||||
publicRegistryDirectoryEntrySchema
|
||||
)
|
||||
|
||||
type RegistryDirectoryEntry = z.infer<typeof registryDirectoryEntrySchema>
|
||||
|
||||
function createPublicRegistryDirectory(
|
||||
entries: readonly RegistryDirectoryEntry[]
|
||||
) {
|
||||
return entries.map(({ name, homepage, url, description }) => ({
|
||||
name,
|
||||
homepage,
|
||||
url,
|
||||
description,
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeRegistryName(value: string) {
|
||||
return value.toLowerCase().replaceAll(" ", "").replace(/^@/, "")
|
||||
}
|
||||
|
||||
export {
|
||||
createPublicRegistryDirectory,
|
||||
normalizeRegistryName,
|
||||
publicRegistryDirectoryEntrySchema,
|
||||
publicRegistryDirectorySchema,
|
||||
registryDirectoryEntrySchema,
|
||||
registryDirectorySchema,
|
||||
registryNamespaceSchema,
|
||||
}
|
||||
export type { RegistryDirectoryEntry }
|
||||
204
apps/v4/lib/registry-health/blob.test.ts
Normal file
204
apps/v4/lib/registry-health/blob.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
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",
|
||||
storeId: "test-store-id",
|
||||
abortSignal: abortController.signal,
|
||||
operations,
|
||||
})
|
||||
).resolves.toEqual(SNAPSHOT)
|
||||
expect(operations.get).toHaveBeenCalledWith(
|
||||
"registry-health/v1/latest.json",
|
||||
{
|
||||
access: "private",
|
||||
token: "test-token",
|
||||
storeId: "test-store-id",
|
||||
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)
|
||||
})
|
||||
})
|
||||
294
apps/v4/lib/registry-health/blob.ts
Normal file
294
apps/v4/lib/registry-health/blob.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
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
|
||||
storeId?: 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,
|
||||
storeId,
|
||||
abortSignal,
|
||||
operations = defaultOperations,
|
||||
}: {
|
||||
token?: string
|
||||
storeId?: string
|
||||
abortSignal?: AbortSignal
|
||||
operations?: BlobOperations
|
||||
}) {
|
||||
const result = await operations.get(LATEST_PATH, {
|
||||
access: "private",
|
||||
token,
|
||||
storeId,
|
||||
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 }
|
||||
117
apps/v4/lib/registry-health/dry-run.test.ts
Normal file
117
apps/v4/lib/registry-health/dry-run.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
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"
|
||||
import { REGISTRY_DRY_RUN_FAILURE_MESSAGE_MAX_LENGTH } from "./schema"
|
||||
|
||||
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("creates a TypeScript project the CLI can load", 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,
|
||||
`
|
||||
import { readFileSync } from "node:fs"
|
||||
|
||||
const tsconfig = JSON.parse(readFileSync("tsconfig.json", "utf8"))
|
||||
if (tsconfig.compilerOptions.paths["@/*"][0] !== "./*") {
|
||||
process.exit(1)
|
||||
}
|
||||
`
|
||||
)
|
||||
|
||||
try {
|
||||
await expect(
|
||||
runLocalCliDryRun({
|
||||
namespace: "@acme",
|
||||
item: "button",
|
||||
registryUrl: "https://acme.example.com/r/{name}.json",
|
||||
cliPath,
|
||||
})
|
||||
).resolves.toMatchObject({ success: true })
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("captures bounded sanitized CLI errors", 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.stderr.write("\\u001b[31m" + "x".repeat(1500) + " useful failure\\u001b[0m"); process.exit(1)'
|
||||
)
|
||||
|
||||
try {
|
||||
const result = await runLocalCliDryRun({
|
||||
namespace: "@acme",
|
||||
item: "button",
|
||||
registryUrl: "https://acme.example.com/r/{name}.json",
|
||||
cliPath,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
failureCode: "exit_1",
|
||||
})
|
||||
expect(result.failureMessage).toContain("useful failure")
|
||||
expect(result.failureMessage).not.toContain("\u001b")
|
||||
expect(result.failureMessage?.length).toBeLessThanOrEqual(
|
||||
REGISTRY_DRY_RUN_FAILURE_MESSAGE_MAX_LENGTH
|
||||
)
|
||||
} finally {
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
186
apps/v4/lib/registry-health/dry-run.ts
Normal file
186
apps/v4/lib/registry-health/dry-run.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
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 { stripVTControlCharacters } from "node:util"
|
||||
|
||||
import { DEFAULT_STYLE, type RegistryDryRunResult } from "./monitor"
|
||||
import { REGISTRY_DRY_RUN_FAILURE_MESSAGE_MAX_LENGTH } from "./schema"
|
||||
|
||||
const MAX_CAPTURED_OUTPUT_LENGTH = 4000
|
||||
|
||||
function getFailureMessage(output: string) {
|
||||
const message = stripVTControlCharacters(output).replace(/\s+/g, " ").trim()
|
||||
return message
|
||||
? message.slice(-REGISTRY_DRY_RUN_FAILURE_MESSAGE_MAX_LENGTH)
|
||||
: undefined
|
||||
}
|
||||
|
||||
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: DEFAULT_STYLE,
|
||||
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 })
|
||||
),
|
||||
fs.writeFile(
|
||||
path.join(temporaryDirectory, "tsconfig.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
compilerOptions: {
|
||||
baseUrl: ".",
|
||||
paths: {
|
||||
"@/*": ["./*"],
|
||||
},
|
||||
jsx: "preserve",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
),
|
||||
])
|
||||
|
||||
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", "pipe", "pipe"],
|
||||
}
|
||||
)
|
||||
let completed = false
|
||||
let timedOut = false
|
||||
let output = ""
|
||||
let forceKillTimeout: NodeJS.Timeout | undefined
|
||||
const captureOutput = (chunk: Buffer | string) => {
|
||||
const value = typeof chunk === "string" ? chunk : chunk.toString("utf8")
|
||||
output = `${output}${value}`.slice(-MAX_CAPTURED_OUTPUT_LENGTH)
|
||||
}
|
||||
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.stdout?.on("data", captureOutput)
|
||||
child.stderr?.on("data", captureOutput)
|
||||
child.on("error", (error) => {
|
||||
captureOutput(error.message)
|
||||
const failureMessage = getFailureMessage(output)
|
||||
finish({
|
||||
success: false,
|
||||
failureCode: timedOut ? "timeout" : "spawn_error",
|
||||
...(failureMessage ? { failureMessage } : {}),
|
||||
durationMs: Date.now() - startedAt,
|
||||
})
|
||||
})
|
||||
child.on("close", (code, signal) => {
|
||||
const failureCode = timedOut
|
||||
? "timeout"
|
||||
: code === 0
|
||||
? undefined
|
||||
: signal
|
||||
? "terminated"
|
||||
: `exit_${code}`
|
||||
const failureMessage =
|
||||
code === 0 ? undefined : getFailureMessage(output)
|
||||
|
||||
finish({
|
||||
success: !timedOut && code === 0,
|
||||
failureCode,
|
||||
...(failureMessage ? { failureMessage } : {}),
|
||||
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 }
|
||||
746
apps/v4/lib/registry-health/monitor.test.ts
Normal file
746
apps/v4/lib/registry-health/monitor.test.ts
Normal file
@@ -0,0 +1,746 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import type { RegistryDirectoryEntry } from "../registry-directory"
|
||||
import { runRegistryMonitor } from "./monitor"
|
||||
import {
|
||||
REGISTRY_DRY_RUN_VERSION,
|
||||
type RegistryMonitorEntryState,
|
||||
type 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,
|
||||
dryRunVersion: REGISTRY_DRY_RUN_VERSION,
|
||||
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("discards invalid pre-tsconfig dry runs and reruns the weekly check", async () => {
|
||||
const now = new Date("2026-09-02T12:00:00.000Z")
|
||||
const recentIndex = Array.from({ length: 24 }, (_, index) => ({
|
||||
checkedAt: new Date(
|
||||
now.getTime() - (24 - index) * 60 * 60 * 1000
|
||||
).toISOString(),
|
||||
outcome: "reachable" as const,
|
||||
status: 200,
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
schemaValid: true,
|
||||
}))
|
||||
const previousState = {
|
||||
...createState(
|
||||
createEntryState({
|
||||
itemNames: ["button"],
|
||||
recentIndex,
|
||||
recentDryRuns: [
|
||||
{
|
||||
checkedAt: "2026-08-25T12:04:27.443Z",
|
||||
item: "button",
|
||||
success: false,
|
||||
failureCode: "exit_1",
|
||||
durationMs: 1500,
|
||||
},
|
||||
{
|
||||
checkedAt: "2026-08-31T12:38:36.084Z",
|
||||
item: "dialog",
|
||||
success: false,
|
||||
failureCode: "exit_1",
|
||||
durationMs: 1700,
|
||||
},
|
||||
],
|
||||
daily: [
|
||||
{
|
||||
date: "2026-08-25",
|
||||
availabilitySuccesses: 0,
|
||||
availabilityObservations: 0,
|
||||
challengeObservations: 0,
|
||||
schemaSuccesses: 0,
|
||||
schemaObservations: 0,
|
||||
itemSuccesses: 0,
|
||||
itemObservations: 0,
|
||||
dryRunSuccesses: 0,
|
||||
dryRunObservations: 1,
|
||||
},
|
||||
{
|
||||
date: "2026-08-31",
|
||||
availabilitySuccesses: 0,
|
||||
availabilityObservations: 0,
|
||||
challengeObservations: 0,
|
||||
schemaSuccesses: 0,
|
||||
schemaObservations: 0,
|
||||
itemSuccesses: 0,
|
||||
itemObservations: 0,
|
||||
dryRunSuccesses: 0,
|
||||
dryRunObservations: 1,
|
||||
},
|
||||
],
|
||||
latestHygiene: {
|
||||
contentTypeJson: true,
|
||||
noDuplicateNames: true,
|
||||
nameMatches: true,
|
||||
},
|
||||
})
|
||||
),
|
||||
dryRunVersion: 0,
|
||||
lastDailyRunAt: "2026-09-02T11:00:00.000Z",
|
||||
lastWeeklyRunAt: "2026-08-31T12:38:36.084Z",
|
||||
}
|
||||
const runDryRun = vi.fn(async () => ({
|
||||
success: true,
|
||||
durationMs: 250,
|
||||
}))
|
||||
|
||||
const result = await runRegistryMonitor({
|
||||
directory: DIRECTORY,
|
||||
previousState,
|
||||
mode: "auto",
|
||||
now,
|
||||
fetchJson: vi.fn(async () => createSuccessfulFetch()),
|
||||
runDryRun,
|
||||
})
|
||||
|
||||
expect(runDryRun).toHaveBeenCalledOnce()
|
||||
expect(result.state.registries["@acme"].recentDryRuns).toEqual([
|
||||
{
|
||||
checkedAt: now.toISOString(),
|
||||
item: "item-0",
|
||||
success: true,
|
||||
durationMs: 250,
|
||||
},
|
||||
])
|
||||
expect(result.state.registries["@acme"].daily.slice(0, 2)).toMatchObject([
|
||||
{ date: "2026-08-25", dryRunSuccesses: 0, dryRunObservations: 0 },
|
||||
{ date: "2026-08-31", dryRunSuccesses: 0, dryRunObservations: 0 },
|
||||
])
|
||||
expect(result.state.lastWeeklyRunAt).toBe(now.toISOString())
|
||||
expect(result.run.diagnostics).toEqual([
|
||||
"Discarded 2 invalid CLI dry-run observations from the pre-tsconfig scaffold.",
|
||||
])
|
||||
expect(result.snapshot.registries["@acme"]).toMatchObject({
|
||||
status: "healthy",
|
||||
breakdown: { installability: 18.5 },
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
it("resolves {style} urls with the default style", async () => {
|
||||
const fetchJson = vi.fn(async () => createSuccessfulFetch())
|
||||
await runRegistryMonitor({
|
||||
directory: [
|
||||
{
|
||||
...DIRECTORY[0],
|
||||
url: "https://acme.example.com/r/{style}/{name}.json",
|
||||
},
|
||||
],
|
||||
mode: "daily",
|
||||
now: NOW,
|
||||
previousState: createState(createEntryState({ itemNames: ["button"] })),
|
||||
fetchJson,
|
||||
runDryRun: vi.fn(),
|
||||
})
|
||||
|
||||
expect(fetchJson).toHaveBeenCalledWith(
|
||||
"https://acme.example.com/r/radix-vega/button.json"
|
||||
)
|
||||
})
|
||||
})
|
||||
615
apps/v4/lib/registry-health/monitor.ts
Normal file
615
apps/v4/lib/registry-health/monitor.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
import { registryItemSchema, registrySchema } from "shadcn/schema"
|
||||
|
||||
import {
|
||||
normalizeRegistryName,
|
||||
type RegistryDirectoryEntry,
|
||||
} from "../registry-directory"
|
||||
import { fetchRegistryJson, type RegistryJsonResult } from "./network"
|
||||
import {
|
||||
REGISTRY_DRY_RUN_VERSION,
|
||||
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
|
||||
|
||||
// Registries cannot declare a default style yet, so resolve {style} urls
|
||||
// with the canonical first base and style combination.
|
||||
export const DEFAULT_STYLE = "radix-vega"
|
||||
|
||||
export type RegistryMonitorMode = RegistryMonitorRun["mode"]
|
||||
|
||||
export type RegistryDryRunResult = {
|
||||
success: boolean
|
||||
failureCode?: string
|
||||
failureMessage?: 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 discardInvalidDryRunHistory(
|
||||
previousState: RegistryMonitorState | null | undefined
|
||||
) {
|
||||
if (!previousState) {
|
||||
return { state: previousState, discardedDryRuns: 0 }
|
||||
}
|
||||
|
||||
const state = structuredClone(previousState)
|
||||
if (state.dryRunVersion === REGISTRY_DRY_RUN_VERSION) {
|
||||
return { state, discardedDryRuns: 0 }
|
||||
}
|
||||
|
||||
let discardedRecentDryRuns = 0
|
||||
let discardedDailyDryRuns = 0
|
||||
|
||||
for (const entry of Object.values(state.registries)) {
|
||||
discardedRecentDryRuns += entry.recentDryRuns.length
|
||||
entry.recentDryRuns = []
|
||||
|
||||
for (const bucket of entry.daily) {
|
||||
discardedDailyDryRuns += bucket.dryRunObservations
|
||||
bucket.dryRunSuccesses = 0
|
||||
bucket.dryRunObservations = 0
|
||||
}
|
||||
}
|
||||
|
||||
const discardedDryRuns = Math.max(
|
||||
discardedRecentDryRuns,
|
||||
discardedDailyDryRuns
|
||||
)
|
||||
state.dryRunVersion = REGISTRY_DRY_RUN_VERSION
|
||||
delete state.lastWeeklyRunAt
|
||||
|
||||
return { state, discardedDryRuns }
|
||||
}
|
||||
|
||||
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).replace("{style}", DEFAULT_STYLE)
|
||||
}
|
||||
|
||||
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 { state: repairedPreviousState, discardedDryRuns } =
|
||||
discardInvalidDryRunHistory(previousState)
|
||||
const checks = getChecks(mode, now, repairedPreviousState)
|
||||
const state: RegistryMonitorState = {
|
||||
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
scoreVersion: REGISTRY_HEALTH_SCORE_VERSION,
|
||||
dryRunVersion: REGISTRY_DRY_RUN_VERSION,
|
||||
updatedAt: now.toISOString(),
|
||||
...(repairedPreviousState?.lastDailyRunAt
|
||||
? { lastDailyRunAt: repairedPreviousState.lastDailyRunAt }
|
||||
: {}),
|
||||
...(repairedPreviousState?.lastWeeklyRunAt
|
||||
? { lastWeeklyRunAt: repairedPreviousState.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] =
|
||||
repairedPreviousState?.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:
|
||||
discardedDryRuns > 0
|
||||
? [
|
||||
`Discarded ${discardedDryRuns} invalid CLI dry-run observations from the pre-tsconfig scaffold.`,
|
||||
]
|
||||
: [],
|
||||
}
|
||||
|
||||
return {
|
||||
state: registryMonitorStateSchema.parse(state),
|
||||
snapshot: registryHealthSnapshotSchema.parse(snapshot),
|
||||
run: registryMonitorRunSchema.parse(run),
|
||||
}
|
||||
}
|
||||
242
apps/v4/lib/registry-health/network.test.ts
Normal file
242
apps/v4/lib/registry-health/network.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
import {
|
||||
createGuardedConnector,
|
||||
detectBotChallenge,
|
||||
fetchRegistryJson,
|
||||
isForbiddenAddress,
|
||||
validateRegistryUrl,
|
||||
} from "./network"
|
||||
|
||||
describe("network destination safety", () => {
|
||||
it.each([
|
||||
"127.0.0.1",
|
||||
"10.0.0.1",
|
||||
"168.63.129.16",
|
||||
"169.254.169.254",
|
||||
"192.0.2.1",
|
||||
"192.88.99.1",
|
||||
"192.168.1.1",
|
||||
"::1",
|
||||
"64:ff9b::7f00:1",
|
||||
"100::",
|
||||
"2001::1",
|
||||
"2002:7f00:1::",
|
||||
"fe80::1",
|
||||
"fec0::1",
|
||||
"fd00:ec2::254",
|
||||
"::ffff:127.0.0.1",
|
||||
])("rejects non-public address %s", (address) => {
|
||||
expect(isForbiddenAddress(address)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"])(
|
||||
"allows public address %s",
|
||||
(address) => {
|
||||
expect(isForbiddenAddress(address)).toBe(false)
|
||||
}
|
||||
)
|
||||
|
||||
it("rejects unsupported protocols and embedded credentials", () => {
|
||||
expect(() => validateRegistryUrl("file:///etc/passwd")).toThrow(
|
||||
"unsupported_protocol"
|
||||
)
|
||||
expect(() =>
|
||||
validateRegistryUrl("https://user:secret@example.com/registry.json")
|
||||
).toThrow("embedded_credentials")
|
||||
})
|
||||
|
||||
it("pins the validated address used by the socket connector", async () => {
|
||||
const connect = vi.fn((_options, callback) => callback(null, {}))
|
||||
const connector = createGuardedConnector({
|
||||
resolve: async () => [{ address: "203.0.114.10", family: 4 }],
|
||||
connect,
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
connector(
|
||||
{
|
||||
hostname: "registry.example.com",
|
||||
protocol: "https:",
|
||||
port: "443",
|
||||
},
|
||||
(error, _socket) => (error ? reject(error) : resolve())
|
||||
)
|
||||
})
|
||||
|
||||
expect(connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
hostname: "203.0.114.10",
|
||||
servername: "registry.example.com",
|
||||
}),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects a hostname when DNS includes a private address", async () => {
|
||||
const connector = createGuardedConnector({
|
||||
resolve: async () => [
|
||||
{ address: "203.0.114.10", family: 4 },
|
||||
{ address: "127.0.0.1", family: 4 },
|
||||
],
|
||||
connect: vi.fn(),
|
||||
})
|
||||
|
||||
await expect(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
connector(
|
||||
{
|
||||
hostname: "registry.example.com",
|
||||
protocol: "https:",
|
||||
port: "443",
|
||||
},
|
||||
(error, _socket) => (error ? reject(error) : resolve())
|
||||
)
|
||||
})
|
||||
).rejects.toThrow("forbidden_address")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchRegistryJson", () => {
|
||||
const dispatcher = {
|
||||
close: vi.fn(async () => {}),
|
||||
}
|
||||
|
||||
it("validates every manual redirect target", async () => {
|
||||
const fetchImpl = vi.fn(
|
||||
async () =>
|
||||
new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "http://127.0.0.1/registry.json" },
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
fetchRegistryJson("https://example.com/registry.json", {
|
||||
fetchImpl,
|
||||
createDispatcher: () => dispatcher as never,
|
||||
attempts: 1,
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
ok: false,
|
||||
failureCode: "forbidden_address",
|
||||
})
|
||||
})
|
||||
|
||||
it("shares one timeout budget across redirects", async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date("2026-08-24T12:00:00.000Z"))
|
||||
|
||||
try {
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => {
|
||||
vi.setSystemTime(new Date("2026-08-24T12:00:00.080Z"))
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://example.com/final.json" },
|
||||
})
|
||||
})
|
||||
.mockImplementationOnce(
|
||||
async (_url: string, init: RequestInit) =>
|
||||
new Promise<Response>((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => {
|
||||
reject(new DOMException("Aborted", "AbortError"))
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
const resultPromise = fetchRegistryJson(
|
||||
"https://example.com/registry.json",
|
||||
{
|
||||
fetchImpl,
|
||||
createDispatcher: () => dispatcher as never,
|
||||
attempts: 1,
|
||||
timeoutMs: 100,
|
||||
}
|
||||
)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
|
||||
await expect(resultPromise).resolves.toMatchObject({
|
||||
ok: false,
|
||||
failureCode: "timeout",
|
||||
})
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it("retries a retryable response and honors Retry-After", async () => {
|
||||
const wait = vi.fn(async () => {})
|
||||
const fetchImpl = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response("busy", {
|
||||
status: 429,
|
||||
headers: { "retry-after": "1" },
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
new Response('{"name":"acme"}', {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
)
|
||||
|
||||
const result = await fetchRegistryJson(
|
||||
"https://example.com/registry.json",
|
||||
{
|
||||
fetchImpl,
|
||||
createDispatcher: () => dispatcher as never,
|
||||
wait,
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(2)
|
||||
expect(wait).toHaveBeenCalledWith(1000)
|
||||
})
|
||||
|
||||
it("enforces the response-size limit", async () => {
|
||||
const result = await fetchRegistryJson(
|
||||
"https://example.com/registry.json",
|
||||
{
|
||||
fetchImpl: async () => new Response("too large"),
|
||||
createDispatcher: () => dispatcher as never,
|
||||
maximumBytes: 3,
|
||||
attempts: 1,
|
||||
}
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
failureCode: "body_too_large",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectBotChallenge", () => {
|
||||
it("recognizes a provider challenge page", () => {
|
||||
expect(
|
||||
detectBotChallenge({
|
||||
status: 403,
|
||||
contentType: "text/html",
|
||||
server: "cloudflare",
|
||||
body: "<title>Just a moment...</title><div class='cf-chl-test'>",
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it("does not classify an ordinary JSON 403 as a challenge", () => {
|
||||
expect(
|
||||
detectBotChallenge({
|
||||
status: 403,
|
||||
contentType: "application/json",
|
||||
server: "",
|
||||
body: '{"error":"forbidden"}',
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
586
apps/v4/lib/registry-health/network.ts
Normal file
586
apps/v4/lib/registry-health/network.ts
Normal file
@@ -0,0 +1,586 @@
|
||||
import { lookup } from "node:dns/promises"
|
||||
import { isIP } from "node:net"
|
||||
import {
|
||||
Agent,
|
||||
buildConnector,
|
||||
fetch as undiciFetch,
|
||||
type Dispatcher,
|
||||
} from "undici"
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000
|
||||
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
|
||||
const DEFAULT_MAX_REDIRECTS = 5
|
||||
const MAX_RETRY_AFTER_MS = 30_000
|
||||
|
||||
type ResolvedAddress = {
|
||||
address: string
|
||||
family: number
|
||||
}
|
||||
|
||||
type ResolveHostname = (hostname: string) => Promise<ResolvedAddress[]>
|
||||
type Connector = ReturnType<typeof buildConnector>
|
||||
|
||||
type FetchLike = (
|
||||
url: string,
|
||||
init: RequestInit & { dispatcher?: Dispatcher }
|
||||
) => Promise<Response>
|
||||
|
||||
export type RegistryJsonResult =
|
||||
| {
|
||||
ok: true
|
||||
json: unknown
|
||||
status: number
|
||||
durationMs: number
|
||||
responseSize: number
|
||||
contentType: string
|
||||
redirectCount: number
|
||||
finalUrl: string
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
failureCode: string
|
||||
reachable: boolean
|
||||
botChallenge: boolean
|
||||
status?: number
|
||||
durationMs: number
|
||||
responseSize?: number
|
||||
contentType?: string
|
||||
redirectCount: number
|
||||
finalUrl: string
|
||||
}
|
||||
|
||||
type RegistryJsonAttemptResult =
|
||||
| Extract<RegistryJsonResult, { ok: true }>
|
||||
| (Extract<RegistryJsonResult, { ok: false }> & {
|
||||
retryAfterMs?: number
|
||||
})
|
||||
|
||||
function parseIpv4(address: string) {
|
||||
if (isIP(address) !== 4) return null
|
||||
|
||||
return address.split(".").map(Number)
|
||||
}
|
||||
|
||||
function parseIpv6(address: string) {
|
||||
if (address.includes("%") || isIP(address) !== 6) return null
|
||||
|
||||
let value = address.toLowerCase()
|
||||
const ipv4Match = value.match(/(\d+\.\d+\.\d+\.\d+)$/)
|
||||
|
||||
if (ipv4Match) {
|
||||
const ipv4 = parseIpv4(ipv4Match[1])
|
||||
if (!ipv4) return null
|
||||
value = value.replace(
|
||||
ipv4Match[1],
|
||||
`${((ipv4[0] << 8) | ipv4[1]).toString(16)}:${(
|
||||
(ipv4[2] << 8) |
|
||||
ipv4[3]
|
||||
).toString(16)}`
|
||||
)
|
||||
}
|
||||
|
||||
const [left = "", right = ""] = value.split("::")
|
||||
const leftParts = left ? left.split(":") : []
|
||||
const rightParts = right ? right.split(":") : []
|
||||
const missing = 8 - leftParts.length - rightParts.length
|
||||
const parts = value.includes("::")
|
||||
? [
|
||||
...leftParts,
|
||||
...Array.from({ length: missing }, () => "0"),
|
||||
...rightParts,
|
||||
]
|
||||
: leftParts
|
||||
|
||||
if (parts.length !== 8) return null
|
||||
|
||||
const parsed = parts.map((part) => Number.parseInt(part || "0", 16))
|
||||
return parsed.every((part) => Number.isInteger(part) && part <= 0xffff)
|
||||
? parsed
|
||||
: null
|
||||
}
|
||||
|
||||
function isForbiddenIpv4(address: string) {
|
||||
const parts = parseIpv4(address)
|
||||
if (!parts) return true
|
||||
|
||||
const [a, b, c] = parts
|
||||
|
||||
return (
|
||||
a === 0 ||
|
||||
a === 10 ||
|
||||
a === 127 ||
|
||||
(a === 100 && b >= 64 && b <= 127) ||
|
||||
(a === 169 && b === 254) ||
|
||||
(a === 172 && b >= 16 && b <= 31) ||
|
||||
(a === 192 && b === 0 && c === 0) ||
|
||||
(a === 192 && b === 0 && c === 2) ||
|
||||
(a === 192 && b === 88 && c === 99) ||
|
||||
(a === 192 && b === 168) ||
|
||||
(a === 198 && (b === 18 || b === 19)) ||
|
||||
(a === 198 && b === 51 && c === 100) ||
|
||||
(a === 203 && b === 0 && c === 113) ||
|
||||
(a === 168 && b === 63 && c === 129 && parts[3] === 16) ||
|
||||
a >= 224
|
||||
)
|
||||
}
|
||||
|
||||
function isForbiddenIpv6(address: string) {
|
||||
const parts = parseIpv6(address)
|
||||
if (!parts) return true
|
||||
|
||||
const isUnspecified = parts.every((part) => part === 0)
|
||||
const isLoopback =
|
||||
parts.slice(0, 7).every((part) => part === 0) && parts[7] === 1
|
||||
const isUniqueLocal = (parts[0] & 0xfe00) === 0xfc00
|
||||
const isLinkLocal = (parts[0] & 0xffc0) === 0xfe80
|
||||
const isSiteLocal = (parts[0] & 0xffc0) === 0xfec0
|
||||
const isMulticast = (parts[0] & 0xff00) === 0xff00
|
||||
const isDocumentation = parts[0] === 0x2001 && parts[1] === 0x0db8
|
||||
const isProtocolAssignment =
|
||||
parts[0] === 0x2001 && parts[1] >= 0 && parts[1] <= 0x01ff
|
||||
const isSixToFour = parts[0] === 0x2002
|
||||
const isDiscardOnly =
|
||||
parts[0] === 0x0100 && parts.slice(1, 4).every((part) => part === 0)
|
||||
const isNat64 =
|
||||
(parts[0] === 0x0064 &&
|
||||
parts[1] === 0xff9b &&
|
||||
parts.slice(2, 6).every((part) => part === 0)) ||
|
||||
(parts[0] === 0x0064 && parts[1] === 0xff9b && parts[2] === 0x0001)
|
||||
const isIpv4Mapped =
|
||||
parts.slice(0, 5).every((part) => part === 0) && parts[5] === 0xffff
|
||||
const isIpv4Compatible = parts.slice(0, 6).every((part) => part === 0)
|
||||
|
||||
if (isIpv4Mapped) {
|
||||
return isForbiddenIpv4(
|
||||
`${parts[6] >> 8}.${parts[6] & 255}.${parts[7] >> 8}.${parts[7] & 255}`
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
isUnspecified ||
|
||||
isLoopback ||
|
||||
isUniqueLocal ||
|
||||
isLinkLocal ||
|
||||
isSiteLocal ||
|
||||
isMulticast ||
|
||||
isDocumentation ||
|
||||
isProtocolAssignment ||
|
||||
isSixToFour ||
|
||||
isDiscardOnly ||
|
||||
isNat64 ||
|
||||
isIpv4Compatible
|
||||
)
|
||||
}
|
||||
|
||||
export function isForbiddenAddress(address: string) {
|
||||
const normalized = address.replace(/^\[|\]$/g, "")
|
||||
const family = isIP(normalized)
|
||||
|
||||
if (family === 4) return isForbiddenIpv4(normalized)
|
||||
if (family === 6) return isForbiddenIpv6(normalized)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function validateRegistryUrl(input: string) {
|
||||
const url = new URL(input)
|
||||
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
throw new Error("unsupported_protocol")
|
||||
}
|
||||
|
||||
if (url.username || url.password) {
|
||||
throw new Error("embedded_credentials")
|
||||
}
|
||||
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "")
|
||||
if (isIP(hostname) && isForbiddenAddress(hostname)) {
|
||||
throw new Error("forbidden_address")
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
const defaultResolve: ResolveHostname = (hostname) =>
|
||||
lookup(hostname, { all: true, verbatim: true })
|
||||
|
||||
export function createGuardedConnector({
|
||||
resolve = defaultResolve,
|
||||
connect = buildConnector({ timeout: DEFAULT_TIMEOUT_MS }),
|
||||
}: {
|
||||
resolve?: ResolveHostname
|
||||
connect?: Connector
|
||||
} = {}): Connector {
|
||||
return (options, callback) => {
|
||||
const hostname = options.hostname.replace(/^\[|\]$/g, "")
|
||||
|
||||
void (async () => {
|
||||
const addresses = isIP(hostname)
|
||||
? [{ address: hostname, family: isIP(hostname) }]
|
||||
: await resolve(hostname)
|
||||
|
||||
if (
|
||||
addresses.length === 0 ||
|
||||
addresses.some(({ address }) => isForbiddenAddress(address))
|
||||
) {
|
||||
throw new Error("forbidden_address")
|
||||
}
|
||||
|
||||
const [{ address }] = addresses
|
||||
connect(
|
||||
{
|
||||
...options,
|
||||
hostname: address,
|
||||
servername: options.servername ?? hostname,
|
||||
},
|
||||
callback
|
||||
)
|
||||
})().catch((error) =>
|
||||
callback(error instanceof Error ? error : new Error("dns_failure"), null)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function createGuardedDispatcher() {
|
||||
return new Agent({
|
||||
connect: createGuardedConnector(),
|
||||
headersTimeout: DEFAULT_TIMEOUT_MS,
|
||||
bodyTimeout: DEFAULT_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
async function readLimitedBody(response: Response, maximumBytes: number) {
|
||||
if (!response.body) return { text: "", size: 0 }
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const chunks: Uint8Array[] = []
|
||||
let size = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
size += value.byteLength
|
||||
|
||||
if (size > maximumBytes) {
|
||||
await reader.cancel()
|
||||
throw new Error("body_too_large")
|
||||
}
|
||||
|
||||
chunks.push(value)
|
||||
}
|
||||
|
||||
const body = new Uint8Array(size)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
body.set(chunk, offset)
|
||||
offset += chunk.byteLength
|
||||
}
|
||||
|
||||
return { text: new TextDecoder().decode(body), size }
|
||||
}
|
||||
|
||||
export function detectBotChallenge({
|
||||
status,
|
||||
contentType,
|
||||
server,
|
||||
body,
|
||||
}: {
|
||||
status: number
|
||||
contentType: string
|
||||
server: string
|
||||
body: string
|
||||
}) {
|
||||
const content = body.toLowerCase()
|
||||
const provider = server.toLowerCase()
|
||||
const isHtml = contentType.toLowerCase().includes("text/html")
|
||||
const marker = [
|
||||
"cf-chl-",
|
||||
"challenge-platform",
|
||||
"just a moment",
|
||||
"attention required",
|
||||
"vercel security checkpoint",
|
||||
"captcha",
|
||||
].some((value) => content.includes(value))
|
||||
|
||||
return (
|
||||
marker &&
|
||||
(isHtml || status === 403 || status === 429) &&
|
||||
(provider.includes("cloudflare") ||
|
||||
provider.includes("vercel") ||
|
||||
content.includes("challenge") ||
|
||||
content.includes("captcha"))
|
||||
)
|
||||
}
|
||||
|
||||
function isRedirect(status: number) {
|
||||
return [301, 302, 303, 307, 308].includes(status)
|
||||
}
|
||||
|
||||
function isRetryableStatus(status: number) {
|
||||
return status === 408 || status === 429 || status >= 500
|
||||
}
|
||||
|
||||
function parseRetryAfter(value: string | null, now: number) {
|
||||
if (!value) return null
|
||||
|
||||
const seconds = Number(value)
|
||||
const delay = Number.isFinite(seconds)
|
||||
? seconds * 1000
|
||||
: new Date(value).getTime() - now
|
||||
|
||||
if (!Number.isFinite(delay) || delay < 0) return null
|
||||
return Math.min(delay, MAX_RETRY_AFTER_MS)
|
||||
}
|
||||
|
||||
function sleep(milliseconds: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, milliseconds))
|
||||
}
|
||||
|
||||
async function fetchOnce(
|
||||
input: string,
|
||||
{
|
||||
timeoutMs,
|
||||
maximumBytes,
|
||||
maximumRedirects,
|
||||
fetchImpl,
|
||||
createDispatcher,
|
||||
}: {
|
||||
timeoutMs: number
|
||||
maximumBytes: number
|
||||
maximumRedirects: number
|
||||
fetchImpl: FetchLike
|
||||
createDispatcher: () => Dispatcher
|
||||
}
|
||||
) {
|
||||
const startedAt = Date.now()
|
||||
let currentUrl = validateRegistryUrl(input)
|
||||
let redirectCount = 0
|
||||
|
||||
while (true) {
|
||||
validateRegistryUrl(currentUrl.toString())
|
||||
const remainingMs = timeoutMs - (Date.now() - startedAt)
|
||||
if (remainingMs <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "timeout",
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
const dispatcher = createDispatcher()
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), remainingMs)
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(currentUrl.toString(), {
|
||||
dispatcher,
|
||||
redirect: "manual",
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"User-Agent": "shadcn-registry-health/1.0",
|
||||
},
|
||||
})
|
||||
|
||||
if (isRedirect(response.status)) {
|
||||
const location = response.headers.get("location")
|
||||
await response.body?.cancel()
|
||||
if (!location) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "redirect_without_location",
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
redirectCount += 1
|
||||
if (redirectCount > maximumRedirects) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "too_many_redirects",
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
currentUrl = validateRegistryUrl(
|
||||
new URL(location, currentUrl).toString()
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
let body: Awaited<ReturnType<typeof readLimitedBody>>
|
||||
try {
|
||||
body = await readLimitedBody(response, maximumBytes)
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "body_too_large",
|
||||
reachable: response.ok,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") ?? ""
|
||||
const botChallenge = detectBotChallenge({
|
||||
status: response.status,
|
||||
contentType,
|
||||
server: response.headers.get("server") ?? "",
|
||||
body: body.text,
|
||||
})
|
||||
|
||||
if (botChallenge) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "bot_challenge",
|
||||
reachable: false,
|
||||
botChallenge: true,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: body.size,
|
||||
contentType,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: `http_${response.status}`,
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: body.size,
|
||||
contentType,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
retryAfterMs:
|
||||
parseRetryAfter(response.headers.get("retry-after"), Date.now()) ??
|
||||
undefined,
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
ok: true,
|
||||
json: JSON.parse(body.text),
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: body.size,
|
||||
contentType,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: "invalid_json",
|
||||
reachable: true,
|
||||
botChallenge: false,
|
||||
status: response.status,
|
||||
durationMs: Date.now() - startedAt,
|
||||
responseSize: body.size,
|
||||
contentType,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
}
|
||||
} catch (error) {
|
||||
const messages = [
|
||||
error instanceof Error ? error.message : "",
|
||||
error instanceof Error && error.cause instanceof Error
|
||||
? error.cause.message
|
||||
: "",
|
||||
]
|
||||
const guardedFailure = [
|
||||
"embedded_credentials",
|
||||
"forbidden_address",
|
||||
"unsupported_protocol",
|
||||
].find((failureCode) => messages.includes(failureCode))
|
||||
const failureCode =
|
||||
guardedFailure ??
|
||||
(error instanceof Error && error.name === "AbortError"
|
||||
? "timeout"
|
||||
: "transport_error")
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
failureCode,
|
||||
reachable: false,
|
||||
botChallenge: false,
|
||||
durationMs: Date.now() - startedAt,
|
||||
redirectCount,
|
||||
finalUrl: currentUrl.toString(),
|
||||
} satisfies RegistryJsonAttemptResult
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
await dispatcher.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchRegistryJson(
|
||||
input: string,
|
||||
options: {
|
||||
attempts?: number
|
||||
timeoutMs?: number
|
||||
maximumBytes?: number
|
||||
maximumRedirects?: number
|
||||
fetchImpl?: FetchLike
|
||||
createDispatcher?: () => Dispatcher
|
||||
wait?: (milliseconds: number) => Promise<unknown>
|
||||
random?: () => number
|
||||
} = {}
|
||||
): Promise<RegistryJsonResult> {
|
||||
const attempts = options.attempts ?? 3
|
||||
const wait = options.wait ?? sleep
|
||||
const random = options.random ?? Math.random
|
||||
let result: Awaited<ReturnType<typeof fetchOnce>> | undefined
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
result = await fetchOnce(input, {
|
||||
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
maximumBytes: options.maximumBytes ?? DEFAULT_MAX_BODY_BYTES,
|
||||
maximumRedirects: options.maximumRedirects ?? DEFAULT_MAX_REDIRECTS,
|
||||
fetchImpl: options.fetchImpl ?? (undiciFetch as unknown as FetchLike),
|
||||
createDispatcher: options.createDispatcher ?? createGuardedDispatcher,
|
||||
})
|
||||
|
||||
const retryable =
|
||||
!result.ok &&
|
||||
!result.botChallenge &&
|
||||
(result.failureCode === "transport_error" ||
|
||||
result.failureCode === "timeout" ||
|
||||
(result.status !== undefined && isRetryableStatus(result.status)))
|
||||
|
||||
if (!retryable || attempt === attempts - 1) {
|
||||
return result
|
||||
}
|
||||
|
||||
await wait(
|
||||
result.retryAfterMs ??
|
||||
Math.min(1000 * 2 ** attempt + random() * 250, 5000)
|
||||
)
|
||||
}
|
||||
|
||||
return result!
|
||||
}
|
||||
75
apps/v4/lib/registry-health/schema.test.ts
Normal file
75
apps/v4/lib/registry-health/schema.test.ts
Normal file
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
315
apps/v4/lib/registry-health/schema.ts
Normal file
315
apps/v4/lib/registry-health/schema.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
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 REGISTRY_DRY_RUN_VERSION = 1 as const
|
||||
export const REGISTRY_DRY_RUN_FAILURE_MESSAGE_MAX_LENGTH = 1000
|
||||
|
||||
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(),
|
||||
failureMessage: z
|
||||
.string()
|
||||
.max(REGISTRY_DRY_RUN_FAILURE_MESSAGE_MAX_LENGTH)
|
||||
.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),
|
||||
dryRunVersion: z.number().int().nonnegative().optional(),
|
||||
updatedAt: z.string().datetime(),
|
||||
lastDailyRunAt: z.string().datetime().optional(),
|
||||
lastWeeklyRunAt: z.string().datetime().optional(),
|
||||
registries: z.record(
|
||||
registryNamespaceSchema,
|
||||
registryMonitorEntryStateSchema
|
||||
),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryMonitorRunSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(REGISTRY_HEALTH_SCHEMA_VERSION),
|
||||
startedAt: z.string().datetime(),
|
||||
completedAt: z.string().datetime(),
|
||||
mode: z.enum(["auto", "hourly", "daily", "weekly", "all"]),
|
||||
totals: z
|
||||
.object({
|
||||
registries: z.number().int().nonnegative(),
|
||||
reachable: z.number().int().nonnegative(),
|
||||
unavailable: z.number().int().nonnegative(),
|
||||
challenges: z.number().int().nonnegative(),
|
||||
itemChecks: z.number().int().nonnegative(),
|
||||
dryRuns: z.number().int().nonnegative(),
|
||||
})
|
||||
.strict(),
|
||||
results: z.record(
|
||||
registryNamespaceSchema,
|
||||
z
|
||||
.object({
|
||||
index: registryIndexObservationSchema.optional(),
|
||||
items: z.array(registryItemObservationSchema),
|
||||
dryRun: registryDryRunObservationSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
),
|
||||
diagnostics: z.array(z.string()),
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const registryMonitorOutputSchema = z
|
||||
.object({
|
||||
state: registryMonitorStateSchema,
|
||||
snapshot: registryHealthSnapshotSchema,
|
||||
run: registryMonitorRunSchema,
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RegistryHealth = z.infer<typeof registryHealthSchema>
|
||||
export type RegistryHealthStatusReasonCode = z.infer<
|
||||
typeof registryHealthStatusReasonCodeSchema
|
||||
>
|
||||
export type RegistryHealthBreakdown = z.infer<
|
||||
typeof registryHealthBreakdownSchema
|
||||
>
|
||||
export type RegistryHealthGlobalMeans = z.infer<
|
||||
typeof registryHealthGlobalMeansSchema
|
||||
>
|
||||
export type RegistryHealthSnapshot = z.infer<
|
||||
typeof registryHealthSnapshotSchema
|
||||
>
|
||||
export type RegistryIndexObservation = z.infer<
|
||||
typeof registryIndexObservationSchema
|
||||
>
|
||||
export type RegistryItemObservation = z.infer<
|
||||
typeof registryItemObservationSchema
|
||||
>
|
||||
export type RegistryDryRunObservation = z.infer<
|
||||
typeof registryDryRunObservationSchema
|
||||
>
|
||||
export type RegistryHealthDailyBucket = z.infer<
|
||||
typeof registryHealthDailyBucketSchema
|
||||
>
|
||||
export type RegistryMonitorEntryState = z.infer<
|
||||
typeof registryMonitorEntryStateSchema
|
||||
>
|
||||
export type RegistryMonitorState = z.infer<typeof registryMonitorStateSchema>
|
||||
export type RegistryMonitorRun = z.infer<typeof registryMonitorRunSchema>
|
||||
export type RegistryMonitorOutput = z.infer<typeof registryMonitorOutputSchema>
|
||||
393
apps/v4/lib/registry-health/score.test.ts
Normal file
393
apps/v4/lib/registry-health/score.test.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import type {
|
||||
RegistryHealthDailyBucket,
|
||||
RegistryIndexObservation,
|
||||
RegistryMonitorEntryState,
|
||||
} from "./schema"
|
||||
import {
|
||||
calculateRegistryHealth,
|
||||
DEFAULT_GLOBAL_MEANS,
|
||||
getRegistryHealthSignals,
|
||||
} from "./score"
|
||||
|
||||
const NOW = new Date("2026-08-24T12:00:00.000Z")
|
||||
|
||||
function createDailyBucket(overrides: Partial<RegistryHealthDailyBucket> = {}) {
|
||||
return {
|
||||
date: "2026-08-24",
|
||||
availabilitySuccesses: 0,
|
||||
availabilityObservations: 0,
|
||||
challengeObservations: 0,
|
||||
schemaSuccesses: 0,
|
||||
schemaObservations: 0,
|
||||
itemSuccesses: 0,
|
||||
itemObservations: 0,
|
||||
dryRunSuccesses: 0,
|
||||
dryRunObservations: 0,
|
||||
...overrides,
|
||||
} satisfies RegistryHealthDailyBucket
|
||||
}
|
||||
|
||||
function createIndexObservations(
|
||||
count = 25,
|
||||
outcome: RegistryIndexObservation["outcome"] = "reachable"
|
||||
) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
checkedAt: new Date(
|
||||
NOW.getTime() - (count - index) * 60 * 60 * 1000
|
||||
).toISOString(),
|
||||
outcome,
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
schemaValid: outcome === "reachable",
|
||||
})) satisfies RegistryIndexObservation[]
|
||||
}
|
||||
|
||||
function createState(overrides: Partial<RegistryMonitorEntryState> = {}) {
|
||||
return {
|
||||
firstObservedAt: "2026-08-22T10:00:00.000Z",
|
||||
lastSuccessfulCheck: "2026-08-24T11:00:00.000Z",
|
||||
status: "healthy",
|
||||
consecutiveSuccessfulIndexes: 25,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityRecoveryRequired: false,
|
||||
itemCursor: 0,
|
||||
itemNames: [],
|
||||
recentIndex: createIndexObservations(),
|
||||
recentDryRuns: [],
|
||||
daily: [
|
||||
createDailyBucket({
|
||||
availabilitySuccesses: 25,
|
||||
availabilityObservations: 25,
|
||||
schemaSuccesses: 25,
|
||||
schemaObservations: 25,
|
||||
}),
|
||||
],
|
||||
latestHygiene: {
|
||||
contentTypeJson: null,
|
||||
noDuplicateNames: null,
|
||||
nameMatches: null,
|
||||
},
|
||||
...overrides,
|
||||
} satisfies RegistryMonitorEntryState
|
||||
}
|
||||
|
||||
describe("calculateRegistryHealth", () => {
|
||||
it("derives the published total from rounded components", () => {
|
||||
const health = calculateRegistryHealth({
|
||||
state: createState(),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.score).toBe(
|
||||
health.breakdown.reliability +
|
||||
health.breakdown.correctness +
|
||||
health.breakdown.installability +
|
||||
health.breakdown.hygiene
|
||||
)
|
||||
expect(health.score).toBeGreaterThanOrEqual(0)
|
||||
expect(health.score).toBeLessThanOrEqual(100)
|
||||
expect(health.statusReason).toEqual({
|
||||
code: "healthy_thresholds",
|
||||
message: "Recent checks are within healthy thresholds",
|
||||
})
|
||||
})
|
||||
|
||||
it("explains when baseline observations are still being collected", () => {
|
||||
const health = calculateRegistryHealth({
|
||||
state: createState({ recentIndex: createIndexObservations(3) }),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("observing")
|
||||
expect(health.statusReason).toEqual({
|
||||
code: "collecting_baseline",
|
||||
message: "Collecting baseline data (3 of 24 checks)",
|
||||
})
|
||||
})
|
||||
|
||||
it("gives unknown hygiene signals half credit", () => {
|
||||
const health = calculateRegistryHealth({
|
||||
state: createState(),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.breakdown.hygiene).toBe(6.25)
|
||||
})
|
||||
|
||||
it("uses a cadence-scaled prior for weekly dry runs", () => {
|
||||
const state = createState({
|
||||
daily: [
|
||||
createDailyBucket({
|
||||
availabilitySuccesses: 25,
|
||||
availabilityObservations: 25,
|
||||
schemaSuccesses: 25,
|
||||
schemaObservations: 25,
|
||||
dryRunSuccesses: 1,
|
||||
dryRunObservations: 1,
|
||||
}),
|
||||
],
|
||||
})
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.breakdown.installability).toBe(18.5)
|
||||
})
|
||||
|
||||
it("excludes challenge observations from availability", () => {
|
||||
const state = createState({
|
||||
recentIndex: [
|
||||
...createIndexObservations(),
|
||||
{
|
||||
checkedAt: NOW.toISOString(),
|
||||
outcome: "bot_challenge",
|
||||
durationMs: 100,
|
||||
redirectCount: 0,
|
||||
},
|
||||
],
|
||||
})
|
||||
const signals = getRegistryHealthSignals(
|
||||
state,
|
||||
"https://example.com/{name}.json",
|
||||
NOW
|
||||
)
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(signals.availability7d).toEqual({
|
||||
successes: 25,
|
||||
observations: 25,
|
||||
})
|
||||
expect(health.monitoringLimited).toBe(true)
|
||||
expect(health.status).toBe("healthy")
|
||||
})
|
||||
|
||||
it("hides a registry only after seven days unavailable", () => {
|
||||
const state = createState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: "2026-08-16T00:00:00.000Z",
|
||||
status: "unavailable",
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 8,
|
||||
availabilityOutageSince: "2026-08-16T00:00:00.000Z",
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: createIndexObservations(8, "unreachable"),
|
||||
})
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("unavailable")
|
||||
expect(health.statusReason).toEqual({
|
||||
code: "index_unavailable",
|
||||
message: "No successful index check in the last 24 hours",
|
||||
})
|
||||
expect(health.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it("keeps an unavailable registry visible before seven days", () => {
|
||||
const state = createState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: "2026-08-20T00:00:00.000Z",
|
||||
status: "unavailable",
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 8,
|
||||
availabilityOutageSince: "2026-08-20T00:00:00.000Z",
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: createIndexObservations(8, "unreachable"),
|
||||
})
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("unavailable")
|
||||
expect(health.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it("keeps a long outage hidden through the first recovery success", () => {
|
||||
const recovery = createIndexObservations(1)[0]
|
||||
const state = createState({
|
||||
firstObservedAt: "2026-08-01T00:00:00.000Z",
|
||||
lastSuccessfulCheck: recovery.checkedAt,
|
||||
status: "unavailable",
|
||||
consecutiveSuccessfulIndexes: 1,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityOutageSince: "2026-08-16T00:00:00.000Z",
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: [...createIndexObservations(24, "unreachable"), recovery],
|
||||
})
|
||||
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("unavailable")
|
||||
expect(health.statusReason?.code).toBe("recovery_pending")
|
||||
expect(health.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it("requires two successful checks to recover from availability degradation", () => {
|
||||
const history = createIndexObservations()
|
||||
const failures = createIndexObservations(3, "unreachable")
|
||||
const successes = createIndexObservations(2)
|
||||
const state = createState({
|
||||
status: "degraded",
|
||||
consecutiveSuccessfulIndexes: 1,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityOutageSince: failures[0].checkedAt,
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: [...history, ...failures, successes[0]],
|
||||
})
|
||||
|
||||
const recovering = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
const recovered = calculateRegistryHealth({
|
||||
state: {
|
||||
...state,
|
||||
consecutiveSuccessfulIndexes: 2,
|
||||
availabilityOutageSince: undefined,
|
||||
availabilityRecoveryRequired: false,
|
||||
recentIndex: [...state.recentIndex, successes[1]],
|
||||
},
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(recovering.status).toBe("degraded")
|
||||
expect(recovering.statusReason).toEqual({
|
||||
code: "recovery_pending",
|
||||
message: "Waiting for a second successful recovery check",
|
||||
})
|
||||
expect(recovered.status).toBe("healthy")
|
||||
})
|
||||
|
||||
it("explains each degraded status condition", () => {
|
||||
const failures = calculateRegistryHealth({
|
||||
state: createState({
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 3,
|
||||
availabilityOutageSince: "2026-08-24T09:00:00.000Z",
|
||||
availabilityRecoveryRequired: true,
|
||||
recentIndex: [
|
||||
...createIndexObservations(),
|
||||
...createIndexObservations(3, "unreachable"),
|
||||
],
|
||||
}),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
const observations = createIndexObservations()
|
||||
const invalidSchema = calculateRegistryHealth({
|
||||
state: createState({
|
||||
recentIndex: [
|
||||
...observations.slice(0, -1),
|
||||
{ ...observations.at(-1)!, schemaValid: false },
|
||||
],
|
||||
}),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
const invalidItems = calculateRegistryHealth({
|
||||
state: createState({
|
||||
daily: [
|
||||
createDailyBucket({
|
||||
availabilitySuccesses: 25,
|
||||
availabilityObservations: 25,
|
||||
schemaSuccesses: 25,
|
||||
schemaObservations: 25,
|
||||
itemSuccesses: 8,
|
||||
itemObservations: 10,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
const dryRuns = calculateRegistryHealth({
|
||||
state: createState({
|
||||
recentDryRuns: [
|
||||
{
|
||||
checkedAt: "2026-08-17T12:00:00.000Z",
|
||||
item: "button",
|
||||
success: false,
|
||||
durationMs: 100,
|
||||
},
|
||||
{
|
||||
checkedAt: "2026-08-24T12:00:00.000Z",
|
||||
item: "button",
|
||||
success: false,
|
||||
durationMs: 100,
|
||||
},
|
||||
],
|
||||
}),
|
||||
registryUrl: "https://example.com/r/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(failures.statusReason).toEqual({
|
||||
code: "consecutive_index_failures",
|
||||
message: "The registry index failed 3 consecutive checks",
|
||||
})
|
||||
expect(invalidSchema.statusReason).toEqual({
|
||||
code: "index_schema_invalid",
|
||||
message: "The registry index failed schema validation",
|
||||
})
|
||||
expect(invalidItems.statusReason).toEqual({
|
||||
code: "item_validation_failures",
|
||||
message: "Sampled registry items are failing validation",
|
||||
})
|
||||
expect(dryRuns.statusReason).toEqual({
|
||||
code: "dry_run_failures",
|
||||
message: "The last two CLI dry-run checks failed",
|
||||
})
|
||||
})
|
||||
|
||||
it("recovers correctness degradation when its condition clears", () => {
|
||||
const state = createState({
|
||||
status: "degraded",
|
||||
recentIndex: createIndexObservations(),
|
||||
})
|
||||
|
||||
const health = calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl: "https://example.com/{name}.json",
|
||||
globalMeans: DEFAULT_GLOBAL_MEANS,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(health.status).toBe("healthy")
|
||||
})
|
||||
})
|
||||
442
apps/v4/lib/registry-health/score.ts
Normal file
442
apps/v4/lib/registry-health/score.ts
Normal file
@@ -0,0 +1,442 @@
|
||||
import {
|
||||
REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
REGISTRY_HEALTH_SCORE_VERSION,
|
||||
type RegistryHealth,
|
||||
type RegistryHealthGlobalMeans,
|
||||
type RegistryMonitorEntryState,
|
||||
type RegistryMonitorState,
|
||||
} from "./schema"
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
const DEFAULT_GLOBAL_MEANS: RegistryHealthGlobalMeans = {
|
||||
availability7d: 0.85,
|
||||
availability30d: 0.85,
|
||||
indexSchema: 0.9,
|
||||
itemValidity: 0.9,
|
||||
dryRun: 0.9,
|
||||
}
|
||||
|
||||
const PRIOR_WEIGHTS = {
|
||||
availability7d: 24,
|
||||
availability30d: 24,
|
||||
indexSchema: 12,
|
||||
itemValidity: 10,
|
||||
dryRun: 3,
|
||||
} as const
|
||||
|
||||
type RateSignal = {
|
||||
successes: number
|
||||
observations: number
|
||||
}
|
||||
|
||||
export type RegistryHealthSignals = {
|
||||
availability7d: RateSignal
|
||||
availability30d: RateSignal
|
||||
indexSchema: RateSignal
|
||||
itemValidity: RateSignal
|
||||
dryRun: RateSignal
|
||||
hygiene: {
|
||||
https: boolean
|
||||
contentTypeJson: boolean | null
|
||||
noDuplicateNames: boolean | null
|
||||
nameMatches: boolean | null
|
||||
}
|
||||
}
|
||||
|
||||
function round(value: number, digits: number) {
|
||||
const factor = 10 ** digits
|
||||
return Math.round((value + Number.EPSILON) * factor) / factor
|
||||
}
|
||||
|
||||
function isWithinWindow(timestamp: string, now: Date, days: number) {
|
||||
const age = now.getTime() - new Date(timestamp).getTime()
|
||||
return age >= 0 && age < days * DAY_MS
|
||||
}
|
||||
|
||||
function isBucketWithinWindow(date: string, now: Date, days: number) {
|
||||
return isWithinWindow(`${date}T00:00:00.000Z`, now, days)
|
||||
}
|
||||
|
||||
function sumDailySignal(
|
||||
state: RegistryMonitorEntryState,
|
||||
now: Date,
|
||||
days: number,
|
||||
successesKey:
|
||||
| "availabilitySuccesses"
|
||||
| "schemaSuccesses"
|
||||
| "itemSuccesses"
|
||||
| "dryRunSuccesses",
|
||||
observationsKey:
|
||||
| "availabilityObservations"
|
||||
| "schemaObservations"
|
||||
| "itemObservations"
|
||||
| "dryRunObservations"
|
||||
) {
|
||||
return state.daily.reduce<RateSignal>(
|
||||
(signal, bucket) => {
|
||||
if (!isBucketWithinWindow(bucket.date, now, days)) {
|
||||
return signal
|
||||
}
|
||||
|
||||
signal.successes += bucket[successesKey]
|
||||
signal.observations += bucket[observationsKey]
|
||||
return signal
|
||||
},
|
||||
{ successes: 0, observations: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
export function getRegistryHealthSignals(
|
||||
state: RegistryMonitorEntryState,
|
||||
registryUrl: string,
|
||||
now: Date
|
||||
): RegistryHealthSignals {
|
||||
const availability7d = state.recentIndex.reduce<RateSignal>(
|
||||
(signal, observation) => {
|
||||
if (
|
||||
observation.outcome === "bot_challenge" ||
|
||||
!isWithinWindow(observation.checkedAt, now, 7)
|
||||
) {
|
||||
return signal
|
||||
}
|
||||
|
||||
signal.observations += 1
|
||||
signal.successes += observation.outcome === "reachable" ? 1 : 0
|
||||
return signal
|
||||
},
|
||||
{ successes: 0, observations: 0 }
|
||||
)
|
||||
|
||||
return {
|
||||
availability7d,
|
||||
availability30d: sumDailySignal(
|
||||
state,
|
||||
now,
|
||||
30,
|
||||
"availabilitySuccesses",
|
||||
"availabilityObservations"
|
||||
),
|
||||
indexSchema: sumDailySignal(
|
||||
state,
|
||||
now,
|
||||
30,
|
||||
"schemaSuccesses",
|
||||
"schemaObservations"
|
||||
),
|
||||
itemValidity: sumDailySignal(
|
||||
state,
|
||||
now,
|
||||
30,
|
||||
"itemSuccesses",
|
||||
"itemObservations"
|
||||
),
|
||||
dryRun: sumDailySignal(
|
||||
state,
|
||||
now,
|
||||
30,
|
||||
"dryRunSuccesses",
|
||||
"dryRunObservations"
|
||||
),
|
||||
hygiene: {
|
||||
https: registryUrl.startsWith("https://"),
|
||||
contentTypeJson: state.latestHygiene.contentTypeJson,
|
||||
noDuplicateNames: state.latestHygiene.noDuplicateNames,
|
||||
nameMatches: state.latestHygiene.nameMatches,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getObservedMean(
|
||||
signals: RateSignal[],
|
||||
minimumObservations: number,
|
||||
fallback: number
|
||||
) {
|
||||
const eligible = signals.filter(
|
||||
(signal) => signal.observations >= minimumObservations
|
||||
)
|
||||
const observations = eligible.reduce(
|
||||
(total, signal) => total + signal.observations,
|
||||
0
|
||||
)
|
||||
|
||||
if (observations === 0) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const successes = eligible.reduce(
|
||||
(total, signal) => total + signal.successes,
|
||||
0
|
||||
)
|
||||
return successes / observations
|
||||
}
|
||||
|
||||
export function calculateGlobalMeans(
|
||||
state: RegistryMonitorState,
|
||||
registryUrls: Record<string, string>,
|
||||
now: Date
|
||||
): RegistryHealthGlobalMeans {
|
||||
const signals = Object.entries(state.registries).map(([name, entry]) =>
|
||||
getRegistryHealthSignals(entry, registryUrls[name] ?? "http://invalid", now)
|
||||
)
|
||||
|
||||
return {
|
||||
availability7d: getObservedMean(
|
||||
signals.map((signal) => signal.availability7d),
|
||||
24,
|
||||
DEFAULT_GLOBAL_MEANS.availability7d
|
||||
),
|
||||
availability30d: getObservedMean(
|
||||
signals.map((signal) => signal.availability30d),
|
||||
24,
|
||||
DEFAULT_GLOBAL_MEANS.availability30d
|
||||
),
|
||||
indexSchema: getObservedMean(
|
||||
signals.map((signal) => signal.indexSchema),
|
||||
12,
|
||||
DEFAULT_GLOBAL_MEANS.indexSchema
|
||||
),
|
||||
itemValidity: getObservedMean(
|
||||
signals.map((signal) => signal.itemValidity),
|
||||
10,
|
||||
DEFAULT_GLOBAL_MEANS.itemValidity
|
||||
),
|
||||
dryRun: getObservedMean(
|
||||
signals.map((signal) => signal.dryRun),
|
||||
3,
|
||||
DEFAULT_GLOBAL_MEANS.dryRun
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function smooth(signal: RateSignal, globalMean: number, priorWeight: number) {
|
||||
return (
|
||||
(signal.successes + globalMean * priorWeight) /
|
||||
(signal.observations + priorWeight)
|
||||
)
|
||||
}
|
||||
|
||||
function getHygienePoints(value: boolean | null) {
|
||||
if (value === null) {
|
||||
return 1.25
|
||||
}
|
||||
|
||||
return value ? 2.5 : 0
|
||||
}
|
||||
|
||||
function getNonChallengeObservations(state: RegistryMonitorEntryState) {
|
||||
return state.recentIndex.filter(
|
||||
(observation) => observation.outcome !== "bot_challenge"
|
||||
)
|
||||
}
|
||||
|
||||
function getUnavailableSince(state: RegistryMonitorEntryState) {
|
||||
return state.availabilityOutageSince ?? state.firstObservedAt
|
||||
}
|
||||
|
||||
function deriveStatus(
|
||||
state: RegistryMonitorEntryState,
|
||||
signals: RegistryHealthSignals,
|
||||
now: Date
|
||||
) {
|
||||
const observations = getNonChallengeObservations(state)
|
||||
const latest = observations.at(-1)
|
||||
const unavailableFor =
|
||||
now.getTime() - new Date(getUnavailableSince(state)).getTime()
|
||||
const unavailable =
|
||||
unavailableFor >= DAY_MS &&
|
||||
(state.availabilityRecoveryRequired || latest?.outcome === "unreachable")
|
||||
|
||||
if (unavailable) {
|
||||
if (latest?.outcome === "reachable") {
|
||||
return {
|
||||
status: "unavailable" as const,
|
||||
statusReason: {
|
||||
code: "recovery_pending" as const,
|
||||
message: "Waiting for a second successful recovery check",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "unavailable" as const,
|
||||
statusReason: {
|
||||
code: "index_unavailable" as const,
|
||||
message: "No successful index check in the last 24 hours",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const observationSpan = latest
|
||||
? new Date(latest.checkedAt).getTime() -
|
||||
new Date(state.firstObservedAt).getTime()
|
||||
: 0
|
||||
|
||||
if (observations.length < 24 || observationSpan < DAY_MS) {
|
||||
return {
|
||||
status: "observing" as const,
|
||||
statusReason: {
|
||||
code: "collecting_baseline" as const,
|
||||
message:
|
||||
observations.length < 24
|
||||
? `Collecting baseline data (${observations.length} of 24 checks)`
|
||||
: "Collecting a full day of baseline data",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const trailingFailures = state.consecutiveIndexFailures
|
||||
const latestSchemaInvalid =
|
||||
latest?.outcome === "reachable" && latest.schemaValid === false
|
||||
const itemDegraded =
|
||||
signals.itemValidity.observations >= 10 &&
|
||||
signals.itemValidity.successes / signals.itemValidity.observations < 0.9
|
||||
const recentDryRuns = state.recentDryRuns.slice(-2)
|
||||
const dryRunDegraded =
|
||||
recentDryRuns.length === 2 &&
|
||||
recentDryRuns.every((observation) => !observation.success)
|
||||
|
||||
if (trailingFailures >= 3) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "consecutive_index_failures" as const,
|
||||
message: `The registry index failed ${trailingFailures} consecutive checks`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (latestSchemaInvalid) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "index_schema_invalid" as const,
|
||||
message: "The registry index failed schema validation",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (itemDegraded) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "item_validation_failures" as const,
|
||||
message: "Sampled registry items are failing validation",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRunDegraded) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "dry_run_failures" as const,
|
||||
message: "The last two CLI dry-run checks failed",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (state.availabilityRecoveryRequired) {
|
||||
return {
|
||||
status: "degraded" as const,
|
||||
statusReason: {
|
||||
code: "recovery_pending" as const,
|
||||
message: "Waiting for a second successful recovery check",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: "healthy" as const,
|
||||
statusReason: {
|
||||
code: "healthy_thresholds" as const,
|
||||
message: "Recent checks are within healthy thresholds",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateRegistryHealth({
|
||||
state,
|
||||
registryUrl,
|
||||
globalMeans,
|
||||
now,
|
||||
}: {
|
||||
state: RegistryMonitorEntryState
|
||||
registryUrl: string
|
||||
globalMeans: RegistryHealthGlobalMeans
|
||||
now: Date
|
||||
}): RegistryHealth {
|
||||
const signals = getRegistryHealthSignals(state, registryUrl, now)
|
||||
const availability7d = smooth(
|
||||
signals.availability7d,
|
||||
globalMeans.availability7d,
|
||||
PRIOR_WEIGHTS.availability7d
|
||||
)
|
||||
const availability30d = smooth(
|
||||
signals.availability30d,
|
||||
globalMeans.availability30d,
|
||||
PRIOR_WEIGHTS.availability30d
|
||||
)
|
||||
const indexSchema = smooth(
|
||||
signals.indexSchema,
|
||||
globalMeans.indexSchema,
|
||||
PRIOR_WEIGHTS.indexSchema
|
||||
)
|
||||
const itemValidity = smooth(
|
||||
signals.itemValidity,
|
||||
globalMeans.itemValidity,
|
||||
PRIOR_WEIGHTS.itemValidity
|
||||
)
|
||||
const dryRun = smooth(
|
||||
signals.dryRun,
|
||||
globalMeans.dryRun,
|
||||
PRIOR_WEIGHTS.dryRun
|
||||
)
|
||||
|
||||
const breakdown = {
|
||||
reliability: round(
|
||||
45 * (0.65 * availability7d + 0.35 * availability30d),
|
||||
3
|
||||
),
|
||||
correctness: round(10 * indexSchema + 15 * itemValidity, 3),
|
||||
installability: round(20 * dryRun, 3),
|
||||
hygiene: round(
|
||||
(signals.hygiene.https ? 2.5 : 0) +
|
||||
getHygienePoints(signals.hygiene.contentTypeJson) +
|
||||
getHygienePoints(signals.hygiene.noDuplicateNames) +
|
||||
getHygienePoints(signals.hygiene.nameMatches),
|
||||
3
|
||||
),
|
||||
}
|
||||
const score = round(
|
||||
breakdown.reliability +
|
||||
breakdown.correctness +
|
||||
breakdown.installability +
|
||||
breakdown.hygiene,
|
||||
3
|
||||
)
|
||||
const { status, statusReason } = deriveStatus(state, signals, now)
|
||||
const unavailableFor =
|
||||
now.getTime() - new Date(getUnavailableSince(state)).getTime()
|
||||
const hidden = status === "unavailable" && unavailableFor >= 7 * DAY_MS
|
||||
const latestObservation = state.recentIndex.at(-1)
|
||||
const checkedAt = latestObservation?.checkedAt ?? state.firstObservedAt
|
||||
|
||||
return {
|
||||
schemaVersion: REGISTRY_HEALTH_SCHEMA_VERSION,
|
||||
scoreVersion: REGISTRY_HEALTH_SCORE_VERSION,
|
||||
status,
|
||||
statusReason,
|
||||
score,
|
||||
breakdown,
|
||||
availability7d: round(availability7d, 6),
|
||||
availability30d: round(availability30d, 6),
|
||||
monitoringLimited: latestObservation?.outcome === "bot_challenge",
|
||||
firstObservedAt: state.firstObservedAt,
|
||||
checkedAt,
|
||||
lastSuccessfulCheck: state.lastSuccessfulCheck,
|
||||
hidden,
|
||||
}
|
||||
}
|
||||
|
||||
export { DEFAULT_GLOBAL_MEANS, PRIOR_WEIGHTS }
|
||||
23
apps/v4/lib/registry-health/state.ts
Normal file
23
apps/v4/lib/registry-health/state.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { RegistryMonitorEntryState } from "./schema"
|
||||
|
||||
function createRegistryMonitorEntryState(now: Date) {
|
||||
return {
|
||||
firstObservedAt: now.toISOString(),
|
||||
status: "observing",
|
||||
consecutiveSuccessfulIndexes: 0,
|
||||
consecutiveIndexFailures: 0,
|
||||
availabilityRecoveryRequired: false,
|
||||
itemCursor: 0,
|
||||
itemNames: [],
|
||||
recentIndex: [],
|
||||
recentDryRuns: [],
|
||||
daily: [],
|
||||
latestHygiene: {
|
||||
contentTypeJson: null,
|
||||
noDuplicateNames: null,
|
||||
nameMatches: null,
|
||||
},
|
||||
} satisfies RegistryMonitorEntryState
|
||||
}
|
||||
|
||||
export { createRegistryMonitorEntryState }
|
||||
346
apps/v4/lib/registry.ts
Normal file
346
apps/v4/lib/registry.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
import { getComponent as getExamplesComponent } from "@/examples/__components__"
|
||||
import { ExamplesIndex } from "@/examples/__index__"
|
||||
import { LRUCache } from "lru-cache"
|
||||
import { registryItemSchema, type registryItemFileSchema } from "shadcn/schema"
|
||||
import { type z } from "zod"
|
||||
|
||||
import { readFileFromRoot } from "@/lib/read-file"
|
||||
import { getComponent as getStylesComponent } from "@/registry/__components__"
|
||||
import { Index as StylesIndex } from "@/registry/__index__"
|
||||
import { BASES } from "@/registry/bases"
|
||||
import { getComponent as getBasesComponent } from "@/registry/bases/__components__"
|
||||
import { Index as BasesIndex } from "@/registry/bases/__index__"
|
||||
|
||||
// LRU cache for cross-request caching of registry items.
|
||||
// File reads are I/O-bound, so caching improves dev server performance.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const registryCache = new LRUCache<string, any>({
|
||||
max: 500,
|
||||
ttl: 1000 * 60 * 5, // 5 minutes (shorter for dev to pick up changes).
|
||||
})
|
||||
|
||||
function getBaseForStyle(styleName: string) {
|
||||
for (const base of BASES) {
|
||||
if (styleName.startsWith(`${base.name}-`)) {
|
||||
return base.name
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getDemoIndexKey(styleName: string) {
|
||||
if (ExamplesIndex[styleName]) {
|
||||
return styleName
|
||||
}
|
||||
|
||||
const base = getBaseForStyle(styleName)
|
||||
if (base && ExamplesIndex[base]) {
|
||||
return base
|
||||
}
|
||||
|
||||
return styleName
|
||||
}
|
||||
|
||||
function getBaseIndex(styleName: string) {
|
||||
const base = getBaseForStyle(styleName)
|
||||
return base ? BasesIndex[base] : null
|
||||
}
|
||||
|
||||
function getStyleIndex(styleName: string) {
|
||||
return StylesIndex[styleName] ?? null
|
||||
}
|
||||
|
||||
function getMergedIndexForStyle(styleName: string) {
|
||||
const styleIndex = getStyleIndex(styleName)
|
||||
const baseIndex = getBaseIndex(styleName)
|
||||
|
||||
if (styleIndex && baseIndex) {
|
||||
return { ...baseIndex, ...styleIndex }
|
||||
}
|
||||
|
||||
return styleIndex ?? baseIndex
|
||||
}
|
||||
|
||||
function getRegistryEntry(name: string, styleName: string) {
|
||||
return getStyleIndex(styleName)?.[name] ?? getBaseIndex(styleName)?.[name]
|
||||
}
|
||||
|
||||
// Lazy components live in per-style __components__ shards (separate from the
|
||||
// metadata indexes) so consumers only pull the requested style's dynamic
|
||||
// imports into their module graph. Lookups mirror getRegistryEntry.
|
||||
function getRegistryComponentEntry(name: string, styleName: string) {
|
||||
const base = getBaseForStyle(styleName)
|
||||
return (
|
||||
getStylesComponent(styleName, name) ??
|
||||
(base ? getBasesComponent(base, name) : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
export function getDemoComponent(name: string, styleName: string) {
|
||||
const key = getDemoIndexKey(styleName)
|
||||
return getExamplesComponent(key, name)
|
||||
}
|
||||
|
||||
export async function getDemoItem(name: string, styleName: string) {
|
||||
const key = getDemoIndexKey(styleName)
|
||||
const demo = ExamplesIndex[key]?.[name]
|
||||
if (!demo) {
|
||||
return null
|
||||
}
|
||||
|
||||
const content = await readFileFromRoot(demo.filePath)
|
||||
|
||||
return {
|
||||
name: demo.name,
|
||||
type: "registry:internal" as const,
|
||||
files: [
|
||||
{
|
||||
path: demo.filePath,
|
||||
content,
|
||||
type: "registry:internal" as const,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export function getRegistryComponent(name: string, styleName: string) {
|
||||
const demoComponent = getDemoComponent(name, styleName)
|
||||
if (demoComponent) {
|
||||
return demoComponent
|
||||
}
|
||||
|
||||
return getRegistryComponentEntry(name, styleName)
|
||||
}
|
||||
|
||||
export async function getRegistryItems(
|
||||
styleName: string,
|
||||
filter?: (item: z.infer<typeof registryItemSchema>) => boolean
|
||||
) {
|
||||
const styleIndex = getMergedIndexForStyle(styleName)
|
||||
|
||||
if (!styleIndex) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = Object.values(styleIndex)
|
||||
|
||||
const filteredEntries = filter ? entries.filter(filter) : entries
|
||||
|
||||
return await Promise.all(
|
||||
filteredEntries.map(async (entry) => {
|
||||
const item = await getRegistryItem(entry.name, styleName)
|
||||
return item
|
||||
})
|
||||
).then((results) => results.filter(Boolean))
|
||||
}
|
||||
|
||||
export async function getRegistryItem(name: string, styleName: string) {
|
||||
const cacheKey = `${styleName}:${name}`
|
||||
|
||||
// Check cache first.
|
||||
if (registryCache.has(cacheKey)) {
|
||||
return registryCache.get(cacheKey)
|
||||
}
|
||||
|
||||
const item = getRegistryEntry(name, styleName)
|
||||
|
||||
if (!item) {
|
||||
registryCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedItem = {
|
||||
...item,
|
||||
files: item.files.map((file: unknown) =>
|
||||
typeof file === "string" ? { path: file } : file
|
||||
),
|
||||
}
|
||||
|
||||
// Convert all file paths to object.
|
||||
// TODO: remove when we migrate to new registry.
|
||||
// Fail early before doing expensive file operations.
|
||||
const result = registryItemSchema.safeParse(normalizedItem)
|
||||
if (!result.success) {
|
||||
registryCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
// Read all files in parallel.
|
||||
let files: typeof result.data.files = await Promise.all(
|
||||
item.files.map(async (file: z.infer<typeof registryItemFileSchema>) => {
|
||||
const content = await getFileContent(file)
|
||||
const relativePath = path.relative(process.cwd(), file.path)
|
||||
|
||||
return {
|
||||
...file,
|
||||
path: relativePath,
|
||||
content,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Fix file paths.
|
||||
files = fixFilePaths(files)
|
||||
|
||||
const parsed = registryItemSchema.safeParse({
|
||||
...result.data,
|
||||
files,
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error(parsed.error.message)
|
||||
registryCache.set(cacheKey, null)
|
||||
return null
|
||||
}
|
||||
|
||||
// Cache the result.
|
||||
registryCache.set(cacheKey, parsed.data)
|
||||
|
||||
return parsed.data
|
||||
}
|
||||
|
||||
async function getFileContent(file: z.infer<typeof registryItemFileSchema>) {
|
||||
let code = await fs.readFile(file.path, "utf-8")
|
||||
|
||||
// Some registry items uses default export.
|
||||
// We want to use named export instead.
|
||||
if (file.type !== "registry:page") {
|
||||
code = code.replaceAll("export default", "export")
|
||||
}
|
||||
|
||||
// Fix imports.
|
||||
code = fixImport(code)
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
function getFileTarget(file: z.infer<typeof registryItemFileSchema>) {
|
||||
let target = file.target
|
||||
|
||||
if (!target || target === "") {
|
||||
const fileName = file.path.split("/").pop()
|
||||
if (
|
||||
file.type === "registry:block" ||
|
||||
file.type === "registry:component" ||
|
||||
file.type === "registry:example"
|
||||
) {
|
||||
target = `components/${fileName}`
|
||||
}
|
||||
|
||||
if (file.type === "registry:ui") {
|
||||
target = `components/ui/${fileName}`
|
||||
}
|
||||
|
||||
if (file.type === "registry:hook") {
|
||||
target = `hooks/${fileName}`
|
||||
}
|
||||
|
||||
if (file.type === "registry:lib") {
|
||||
target = `lib/${fileName}`
|
||||
}
|
||||
}
|
||||
|
||||
return target ?? ""
|
||||
}
|
||||
|
||||
function fixFilePaths(files: z.infer<typeof registryItemSchema>["files"]) {
|
||||
if (!files) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Resolve all paths relative to the first file's directory.
|
||||
const firstFilePath = files[0].path
|
||||
const firstFilePathDir = path.dirname(firstFilePath)
|
||||
|
||||
return files.map((file) => {
|
||||
return {
|
||||
...file,
|
||||
path: path.relative(firstFilePathDir, file.path),
|
||||
target: getFileTarget(file),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function fixImport(content: string) {
|
||||
content = content.replace(
|
||||
/@\/styles\/([\w-]+)\/(ui-rtl|ui)\/([\w-]+)/g,
|
||||
(match, _styleName, type, component) => {
|
||||
if (type === "ui" || type === "ui-rtl") {
|
||||
return `@/components/ui/${component}`
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
)
|
||||
|
||||
const regex = /@\/(.+?)\/((?:.*?\/)?(?:components|ui|hooks|lib))\/([\w-]+)/g
|
||||
|
||||
const replacement = (
|
||||
match: string,
|
||||
path: string,
|
||||
type: string,
|
||||
component: string
|
||||
) => {
|
||||
if (type.endsWith("components")) {
|
||||
return `@/components/${component}`
|
||||
} else if (type.endsWith("ui")) {
|
||||
return `@/components/ui/${component}`
|
||||
} else if (type.endsWith("hooks")) {
|
||||
return `@/hooks/${component}`
|
||||
} else if (type.endsWith("lib")) {
|
||||
return `@/lib/${component}`
|
||||
}
|
||||
|
||||
return match
|
||||
}
|
||||
|
||||
return content.replace(regex, replacement)
|
||||
}
|
||||
|
||||
export type FileTree = {
|
||||
name: string
|
||||
path?: string
|
||||
children?: FileTree[]
|
||||
}
|
||||
|
||||
export function createFileTreeForRegistryItemFiles(
|
||||
files: Array<{ path: string; target?: string }>
|
||||
) {
|
||||
const root: FileTree[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.target ?? file.path
|
||||
const parts = path.split("/")
|
||||
let currentLevel = root
|
||||
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
const part = parts[i]
|
||||
const isFile = i === parts.length - 1
|
||||
const existingNode = currentLevel.find((node) => node.name === part)
|
||||
|
||||
if (existingNode) {
|
||||
if (isFile) {
|
||||
// Update existing file node with full path
|
||||
existingNode.path = path
|
||||
} else {
|
||||
// Move to next level in the tree
|
||||
currentLevel = existingNode.children!
|
||||
}
|
||||
} else {
|
||||
const newNode: FileTree = isFile
|
||||
? { name: part, path }
|
||||
: { name: part, children: [] }
|
||||
|
||||
currentLevel.push(newNode)
|
||||
|
||||
if (!isFile) {
|
||||
currentLevel = newNode.children!
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return root
|
||||
}
|
||||
221
apps/v4/lib/rehype.ts
Normal file
221
apps/v4/lib/rehype.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import { ExamplesIndex } from "@/examples/__index__"
|
||||
import { u } from "unist-builder"
|
||||
import { visit } from "unist-util-visit"
|
||||
|
||||
import { formatCode } from "@/lib/format-code"
|
||||
import { Index as StylesIndex } from "@/registry/__index__"
|
||||
import { getActiveStyle } from "@/registry/_legacy-styles"
|
||||
import { BASES } from "@/registry/bases"
|
||||
import { Index as BasesIndex } from "@/registry/bases/__index__"
|
||||
|
||||
export { formatCode } from "@/lib/format-code"
|
||||
|
||||
function getBaseForStyle(styleName: string) {
|
||||
for (const base of BASES) {
|
||||
if (styleName.startsWith(`${base.name}-`)) {
|
||||
return base.name
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getDemoFilePath(name: string, styleName: string) {
|
||||
const base = getBaseForStyle(styleName)
|
||||
const demo =
|
||||
ExamplesIndex[styleName]?.[name] ??
|
||||
(base ? ExamplesIndex[base]?.[name] : undefined)
|
||||
if (!demo) return null
|
||||
|
||||
return demo.filePath
|
||||
}
|
||||
|
||||
function getRegistryEntry(name: string, styleName: string) {
|
||||
const base = getBaseForStyle(styleName)
|
||||
return (
|
||||
StylesIndex[styleName]?.[name] ??
|
||||
(base ? BasesIndex[base]?.[name] : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
interface UnistNode {
|
||||
type: string
|
||||
name?: string
|
||||
tagName?: string
|
||||
value?: string
|
||||
properties?: Record<string, unknown>
|
||||
attributes?: {
|
||||
name: string
|
||||
value: unknown
|
||||
type?: string
|
||||
}[]
|
||||
children?: UnistNode[]
|
||||
}
|
||||
|
||||
export interface UnistTree {
|
||||
type: string
|
||||
children: UnistNode[]
|
||||
}
|
||||
|
||||
interface NodeToProcess {
|
||||
node: UnistNode
|
||||
type: "ComponentSource" | "ComponentPreview"
|
||||
name: string
|
||||
styleName: string
|
||||
fileName?: string
|
||||
srcPath?: string
|
||||
hideCode?: boolean
|
||||
}
|
||||
|
||||
export function rehypeComponent() {
|
||||
return async (tree: UnistTree) => {
|
||||
const activeStyle = await getActiveStyle()
|
||||
const nodesToProcess: NodeToProcess[] = []
|
||||
|
||||
visit(tree, (node: UnistNode) => {
|
||||
const { value: srcPath } =
|
||||
(getNodeAttributeByName(node, "src") as {
|
||||
name: string
|
||||
value?: string
|
||||
type?: string
|
||||
}) || {}
|
||||
|
||||
if (node.name === "ComponentSource") {
|
||||
const name = getNodeAttributeByName(node, "name")?.value as string
|
||||
const fileName = getNodeAttributeByName(node, "fileName")?.value as
|
||||
| string
|
||||
| undefined
|
||||
const styleName =
|
||||
(getNodeAttributeByName(node, "styleName")?.value as string) ||
|
||||
activeStyle.name
|
||||
|
||||
if (name || srcPath) {
|
||||
nodesToProcess.push({
|
||||
node,
|
||||
type: "ComponentSource",
|
||||
name,
|
||||
styleName,
|
||||
fileName,
|
||||
srcPath,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (node.name === "ComponentPreview") {
|
||||
const name = getNodeAttributeByName(node, "name")?.value as string
|
||||
const styleName =
|
||||
(getNodeAttributeByName(node, "styleName")?.value as string) ||
|
||||
activeStyle.name
|
||||
const hideCode = isTruthyMdxAttribute(
|
||||
getNodeAttributeByName(node, "hideCode")
|
||||
)
|
||||
|
||||
if (name) {
|
||||
nodesToProcess.push({
|
||||
node,
|
||||
type: "ComponentPreview",
|
||||
name,
|
||||
styleName,
|
||||
hideCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
nodesToProcess.map(async (item) => {
|
||||
try {
|
||||
if (item.type === "ComponentPreview" && item.hideCode) {
|
||||
return
|
||||
}
|
||||
|
||||
let src: string | null = null
|
||||
|
||||
if (item.srcPath) {
|
||||
src = path.join(process.cwd(), item.srcPath)
|
||||
} else {
|
||||
src = getDemoFilePath(item.name, item.styleName)
|
||||
|
||||
if (!src) {
|
||||
const component = getRegistryEntry(item.name, item.styleName)
|
||||
|
||||
if (!component?.files) {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === "ComponentSource" && item.fileName) {
|
||||
src =
|
||||
component.files.find((file: unknown) => {
|
||||
if (typeof file === "string") {
|
||||
return (
|
||||
file.endsWith(`${item.fileName}.tsx`) ||
|
||||
file.endsWith(`${item.fileName}.ts`)
|
||||
)
|
||||
}
|
||||
return false
|
||||
}) || component.files[0]?.path
|
||||
} else {
|
||||
src = component.files[0]?.path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(path.join(process.cwd(), src), "utf8")
|
||||
const source = await formatCode(raw, item.styleName)
|
||||
|
||||
item.node.children?.push(
|
||||
u("element", {
|
||||
tagName: "pre",
|
||||
properties: {
|
||||
__src__: src,
|
||||
},
|
||||
children: [
|
||||
u("element", {
|
||||
tagName: "code",
|
||||
properties: {
|
||||
className: ["language-tsx"],
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: source,
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getNodeAttributeByName(node: UnistNode, name: string) {
|
||||
return node.attributes?.find((attribute) => attribute.name === name)
|
||||
}
|
||||
|
||||
function isTruthyMdxAttribute(
|
||||
attribute?: {
|
||||
value?: unknown
|
||||
} | null
|
||||
) {
|
||||
if (!attribute) return false
|
||||
|
||||
if (!("value" in attribute)) return true
|
||||
|
||||
const { value } = attribute
|
||||
|
||||
if (value === undefined || value === null) return true
|
||||
if (typeof value === "boolean") return value
|
||||
if (typeof value === "string") return value !== "false"
|
||||
return Boolean(value)
|
||||
}
|
||||
7
apps/v4/lib/source.ts
Normal file
7
apps/v4/lib/source.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { docs } from "@/.source/server"
|
||||
import { loader } from "fumadocs-core/source"
|
||||
|
||||
export const source = loader({
|
||||
baseUrl: "/docs",
|
||||
source: docs.toFumadocsSource(),
|
||||
})
|
||||
5
apps/v4/lib/themes.ts
Normal file
5
apps/v4/lib/themes.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { baseColors } from "@/registry/_legacy-base-colors"
|
||||
|
||||
export const THEMES = baseColors
|
||||
.filter((theme) => !["slate", "stone", "gray", "zinc"].includes(theme.name))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
5
apps/v4/lib/utils.ts
Normal file
5
apps/v4/lib/utils.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { cn } from "cn"
|
||||
|
||||
export function absoluteUrl(path: string) {
|
||||
return `${process.env.NEXT_PUBLIC_APP_URL}${path}`
|
||||
}
|
||||
Reference in New Issue
Block a user