mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-23 06:10:32 +00:00
*PR Created by the Glary-Bot Agent* --- ## Summary The [`/cloud/supported-nodes`](https://comfy-website-preview-pr-12271.vercel.app/cloud/supported-nodes) page was rendering packs without descriptions, icons, or download counts (PR #12271 preview, [`Release: Website` run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/25866708684/job/76010642686)). The registry enrichment in `apps/website/src/utils/cloudNodes.registry.ts` was silently failing for **two** reasons: 1. **Missing `limit` query parameter.** `api.comfy.org /nodes` applies a default page size of `10` when no `limit` is sent. Each batch of up to 50 `node_id` filters was therefore truncated to 10 results, dropping metadata for every pack past the first ten. 2. **Schema rejected `null` for optional arrays.** The registry serializes empty server-side slices as JSON `null`, so any pack with `supported_os: null` or `supported_accelerators: null` failed Zod validation — and because parse failure is not retryable, the **entire batch** got `null` enrichment. Both bugs produce the same user-visible symptom ("packs fetched, but no metadata"), so they were entangled. ## Changes (2 files) - `cloudNodes.registry.ts`: send `?limit=<batch length>` on every batch and accept `null` for all optional registry fields. The schema normalizes `null → undefined` at the parse boundary via `.transform()`, so the parsed shape continues to match the generated OpenAPI `Node` type contract; downstream code (`toDomainPack`, the rendered `Pack`) is unchanged. - `cloudNodes.registry.test.ts`: two new regression tests: - Server-side default page size: simulates the pre-fix behavior (default `limit=10` truncates) and asserts all 30 batched IDs are enriched. - `null` registry fields: asserts `null` values are normalized to `undefined` on the parsed pack. ## Verification End-to-end fetch against the live registry on this branch (14 packs from the current snapshot): ``` Requested: 14, enriched: 14 comfyui-kjnodes downloads=3,404,416 rgthree-comfy downloads=3,105,034 comfyui-easy-use downloads=2,829,702 comfyui-impact-pack downloads=2,680,589 comfyui_essentials downloads=2,418,367 (supported_os null → undefined) ComfyUI-Crystools downloads=1,729,087 comfyui_layerstyle downloads=1,696,809 comfyui_ultimatesdupscale downloads=1,478,763 comfyui_ipadapter_plus downloads=1,236,442 (supported_os null → undefined) was-node-suite-comfyui downloads=993,960 (supported_os null → undefined) comfyui-advanced-controlnet downloads=600,849 comfyui-animatediff-evolved downloads=503,831 comfyui-cogvideoxwrapper downloads=121,716 (supported_os null → undefined) comfyui_steudio downloads=58,470 (supported_os null → undefined) ``` 5 of 14 packs returned `null` arrays — all parse cleanly now. Sort-by-downloads (already implemented in `useFilteredPacks.ts`) becomes meaningful again once `downloads` is populated. Quality gates: - `pnpm --filter @comfyorg/website test:unit` → 77/77 pass (includes 2 new regression tests) - `pnpm --filter @comfyorg/website typecheck` → 0 errors, 0 warnings on changed files - `pnpm exec eslint` on changed files → clean - `pnpm exec oxfmt --check` on changed files → clean ## Follow-ups (separate tickets) - "New" badge + `dateAdded` field for newly added packs. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-12307-fix-website-restore-registry-metadata-for-cloud-nodes-catalog-3626d73d365081288a2cfc30003160cf) by [Unito](https://www.unito.io) Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
212 lines
5.2 KiB
TypeScript
212 lines
5.2 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
import type { components } from '@comfyorg/registry-types'
|
|
|
|
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
|
|
const DEFAULT_TIMEOUT_MS = 5_000
|
|
const BATCH_SIZE = 50
|
|
|
|
export type RegistryPack = components['schemas']['Node']
|
|
|
|
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
|
|
return value ?? undefined
|
|
}
|
|
|
|
const optionalString = z.string().nullish().transform(nullToUndefined)
|
|
const optionalNumber = z.number().nullish().transform(nullToUndefined)
|
|
const optionalStringArray = z
|
|
.array(z.string())
|
|
.nullish()
|
|
.transform(nullToUndefined)
|
|
|
|
const RegistryPackSchema = z
|
|
.object({
|
|
id: optionalString,
|
|
name: optionalString,
|
|
description: optionalString,
|
|
icon: optionalString,
|
|
banner_url: optionalString,
|
|
repository: optionalString,
|
|
license: optionalString,
|
|
downloads: optionalNumber,
|
|
github_stars: optionalNumber,
|
|
created_at: optionalString,
|
|
supported_os: optionalStringArray,
|
|
supported_accelerators: optionalStringArray,
|
|
publisher: z
|
|
.object({
|
|
id: optionalString,
|
|
name: optionalString
|
|
})
|
|
.passthrough()
|
|
.nullish()
|
|
.transform(nullToUndefined),
|
|
latest_version: z
|
|
.object({
|
|
version: optionalString,
|
|
createdAt: optionalString
|
|
})
|
|
.passthrough()
|
|
.nullish()
|
|
.transform(nullToUndefined)
|
|
})
|
|
.passthrough()
|
|
|
|
const RegistryListResponseSchema = z
|
|
.object({
|
|
nodes: z.array(RegistryPackSchema)
|
|
})
|
|
.passthrough()
|
|
|
|
interface FetchRegistryOptions {
|
|
baseUrl?: string
|
|
timeoutMs?: number
|
|
fetchImpl?: typeof fetch
|
|
}
|
|
|
|
export async function fetchRegistryPacks(
|
|
packIds: readonly string[],
|
|
options: FetchRegistryOptions = {}
|
|
): Promise<Map<string, RegistryPack | null>> {
|
|
const uniquePackIds = [...new Set(packIds.filter((id) => id.length > 0))]
|
|
if (uniquePackIds.length === 0) {
|
|
return new Map()
|
|
}
|
|
|
|
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
|
|
const timeoutMs = clampTimeoutMs(options.timeoutMs)
|
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
|
|
const batches = chunk(uniquePackIds, BATCH_SIZE)
|
|
const resolved = new Map<string, RegistryPack | null>()
|
|
let successCount = 0
|
|
let failureCount = 0
|
|
|
|
for (const batch of batches) {
|
|
const nodes = await fetchBatchWithRetry(
|
|
fetchImpl,
|
|
baseUrl,
|
|
batch,
|
|
timeoutMs
|
|
)
|
|
if (!nodes) {
|
|
failureCount += 1
|
|
for (const packId of batch) {
|
|
resolved.set(packId, null)
|
|
}
|
|
continue
|
|
}
|
|
|
|
successCount += 1
|
|
const nodesById = new Map(
|
|
nodes
|
|
.map((node) => [node.id, node] as const)
|
|
.filter(([id]) => typeof id === 'string' && id.length > 0)
|
|
)
|
|
|
|
for (const packId of batch) {
|
|
resolved.set(packId, nodesById.get(packId) ?? null)
|
|
}
|
|
}
|
|
|
|
if (failureCount > 0) {
|
|
console.warn(
|
|
`[cloud-nodes] registry enrichment: ${successCount}/${batches.length} batches succeeded, ${failureCount} failed`
|
|
)
|
|
}
|
|
|
|
if (successCount === 0) {
|
|
return new Map()
|
|
}
|
|
|
|
return resolved
|
|
}
|
|
|
|
async function fetchBatchWithRetry(
|
|
fetchImpl: typeof fetch,
|
|
baseUrl: string,
|
|
packIds: readonly string[],
|
|
timeoutMs: number
|
|
): Promise<RegistryPack[] | null> {
|
|
const firstAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
|
|
if (firstAttempt.kind === 'ok') {
|
|
return firstAttempt.nodes
|
|
}
|
|
if (!firstAttempt.retryable) {
|
|
return null
|
|
}
|
|
|
|
const secondAttempt = await fetchBatch(fetchImpl, baseUrl, packIds, timeoutMs)
|
|
if (secondAttempt.kind === 'ok') {
|
|
return secondAttempt.nodes
|
|
}
|
|
return null
|
|
}
|
|
|
|
type BatchResponse =
|
|
| { kind: 'ok'; nodes: RegistryPack[] }
|
|
| { kind: 'err'; retryable: boolean }
|
|
|
|
async function fetchBatch(
|
|
fetchImpl: typeof fetch,
|
|
baseUrl: string,
|
|
packIds: readonly string[],
|
|
timeoutMs: number
|
|
): Promise<BatchResponse> {
|
|
const params = new URLSearchParams()
|
|
params.set('limit', String(packIds.length))
|
|
for (const packId of packIds) {
|
|
params.append('node_id', packId)
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
|
|
try {
|
|
const res = await fetchImpl(`${baseUrl}/nodes?${params.toString()}`, {
|
|
method: 'GET',
|
|
headers: {
|
|
Accept: 'application/json'
|
|
},
|
|
signal: controller.signal
|
|
})
|
|
|
|
if (!res.ok) {
|
|
return {
|
|
kind: 'err',
|
|
retryable: res.status === 429 || (res.status >= 500 && res.status < 600)
|
|
}
|
|
}
|
|
|
|
const rawBody: unknown = await res.json()
|
|
const parsed = RegistryListResponseSchema.safeParse(rawBody)
|
|
if (!parsed.success) {
|
|
return { kind: 'err', retryable: false }
|
|
}
|
|
return { kind: 'ok', nodes: parsed.data.nodes as RegistryPack[] }
|
|
} catch {
|
|
return { kind: 'err', retryable: true }
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
function chunk<T>(values: readonly T[], size: number): T[][] {
|
|
const chunks: T[][] = []
|
|
for (let i = 0; i < values.length; i += size) {
|
|
chunks.push(values.slice(i, i + size))
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
function clampTimeoutMs(candidate: number | undefined): number {
|
|
if (
|
|
typeof candidate !== 'number' ||
|
|
!Number.isFinite(candidate) ||
|
|
candidate <= 0
|
|
) {
|
|
return DEFAULT_TIMEOUT_MS
|
|
}
|
|
return Math.floor(candidate)
|
|
}
|