Files
ComfyUI_frontend/apps/website/src/utils/cloudNodes.ts
Christian Byrne 469a5edf99 feat: cloud-nodes catalog at /cloud/supported-nodes (#11903)
*PR Created by the Glary-Bot Agent*

---

## Summary

Adds a comfy.org page that lists every custom-node pack supported on
Comfy Cloud, with per-pack detail subpages. Data is fetched at build
time from `cloud.comfy.org/api/object_info` (gated by
`WEBSITE_CLOUD_API_KEY`), sanitized of user content, joined with public
registry metadata from `api.comfy.org/nodes`, and falls back to a
committed snapshot — mirroring the existing Ashby careers integration
pattern.

- Index: `/cloud/supported-nodes` (en) and
`/zh-CN/cloud/supported-nodes` (zh-CN)
- Detail: `/cloud/supported-nodes/[pack]` and
`/zh-CN/cloud/supported-nodes/[pack]`, generated via `getStaticPaths()`
from the same fetcher as the index so the two routes can never diverge.

## What's new

**Shared package (extracted)**
- `@comfyorg/object-info-parser` — Zod schemas (`zComfyNodeDef`,
`validateComfyNodeDef`), node-source classifier (`getNodeSource`,
`isCustomNode`, `CORE_NODE_MODULES`), and helpers (`groupNodesByPack`,
`sanitizeUserContent`). `src/schemas/nodeDefSchema.ts` and
`src/types/nodeSource.ts` become 1-line re-export shims; existing
imports keep compiling.

**Build-time pipeline**
- `apps/website/src/utils/cloudNodes.ts` — Ashby-style fetcher:
retry/backoff `[1s, 2s, 4s]`, 10 s timeout via AbortController, Zod
envelope + per-node validation, snapshot fallback, memoized via
module-level `inflight` promise.
- `apps/website/src/utils/cloudNodes.registry.ts` — Public registry
enrichment (no auth, batches of 50, single retry, soft-fail).
- `apps/website/src/utils/cloudNodes.ci.ts` — GitHub Actions annotations
+ step summary mirroring the Ashby reporter.
- `apps/website/src/utils/cloudNodes.build.ts` — Single
`loadPacksForBuild()` consumed by both index and detail pages so they
share one source of truth.
- `apps/website/scripts/refresh-cloud-nodes-snapshot.ts` — atomic-rename
refresh CLI that walks pack/node string fields with a user-content
extension regex *before* renaming the snapshot into place.
- Mandatory user-content sanitization strips uploaded filenames from
combo lists (`LoadImage`, `LoadImageMask`, `LoadImageOutput`,
`LoadVideo`, `LoadAudio` zeroed; any combo value matching
`/\.(png|jpe?g|webp|gif|mp4|mov|webm|wav|mp3|flac|ogg|safetensors|ckpt|pt)$/i`
filtered).

**Page + components**
- `apps/website/src/pages/cloud/supported-nodes.astro` (en) + zh-CN
twin.
- `apps/website/src/pages/cloud/supported-nodes/[pack].astro` detail
(en) + zh-CN twin, async `getStaticPaths` driven by
`loadPacksForBuild()`.
-
`apps/website/src/components/cloud-nodes/{HeroSection,PackGridSection,PackCard,PackBanner,NodeList,PackDetail}.vue`
— Vue 3.5 destructured props, `cn()` from `@comfyorg/tailwind-utils`,
design-system tokens only, no PrimeVue.
- Pack card name links to its detail page; banner uses the shared
`fallback-gradient-avatar.svg` asset (copied into
`apps/website/public/assets/images/`) when `banner_url` and `icon` are
missing.
- 25 new `cloudNodes.*` i18n keys in `en` + `zh-CN`.

**Tests**
- 33 unit tests in `@comfyorg/object-info-parser` (schemas, classifier,
sanitizer, grouping).
- 19 new website unit tests covering fetcher (10), CI reporter (6),
registry enrichment (3) — Ashby patterns mirrored.
- E2E: index smoke + search + banner + detail click-through + direct
visit + zh-CN parity.

## Required maintainer follow-up

GitHub Apps cannot push `.github/workflows/*` changes (push was rejected
with `refusing to allow a GitHub App to create or update workflow …
without workflows permission`), so the workflow edits prepared in this
branch were reverted in commit `9be2abce8`. The intended diffs are
documented as copy-paste-ready snippets in `apps/website/README.md`
under the new "Cloud nodes integration → CI wiring" section.

A maintainer must:

1. Provision `WEBSITE_CLOUD_API_KEY` in the repo secrets and the Vercel
project env.
2. Apply the `ci-website-build.yaml` and
`ci-vercel-website-preview.yaml` diffs documented in the README directly
to `main` (or as a follow-up commit on this branch with a maintainer
account).

The committed snapshot lets builds succeed without the secret while the
maintainer step is pending — pages render from
`apps/website/src/data/cloud-nodes.snapshot.json`.

## Self-review (Oracle)

Two warnings caught and fixed in commits `deba5ab02` and `99dfc3381`:
- Index/detail pages now share a single source of truth
(`loadPacksForBuild`), so a fresh fetch can't expose packs whose detail
routes weren't generated.
- Refresh script validates parsed snapshot fields *before* the atomic
rename, instead of regex-scanning the serialized JSON after the file is
already in place.

## Quality gates (local)

```
pnpm --filter @comfyorg/object-info-parser test  →  33 passed
pnpm --filter @comfyorg/website test:unit         →  42 passed
pnpm --filter @comfyorg/website typecheck         →  0 errors
pnpm --filter @comfyorg/website build             →  47 pages built (incl. 6 cloud-nodes routes)
pnpm lint                                         →  0 errors (1 pre-existing warning in unrelated test file)
pnpm knip                                         →  0 errors (1 pre-existing tag hint in unrelated file)
```

E2E (`pnpm --filter @comfyorg/website test:e2e`) is intended to be run
by the Vercel/CI pipelines.

## Manual verification

Built `dist/`, served locally on port 4321, drove with Playwright:
- `/cloud/supported-nodes` renders both pack cards, search input, sort
dropdown
- `/cloud/supported-nodes/comfyui-impact-pack` renders the metadata grid
(publisher, downloads, stars, version, license, last updated) and 3
categorized node sections with 5 nodes total
- `/zh-CN/cloud/supported-nodes` localizes hero (`Comfy Cloud 上的自定义节点`),
label (`云端节点目录`), search placeholder (`搜索节点包或节点名称`), sort
- `/zh-CN/cloud/supported-nodes/comfyui-controlnet-aux` localizes every
metadata label (`查看仓库`, `发布者`, `下载量`, `GitHub 星标`, `最新版本`, `许可证`,
`最后更新`) and renders dates with `Intl.DateTimeFormat('zh-CN')`
(`2026年4月27日`)
- Search input narrows pack count from 2 to 1 when typing `impact`
(verified via DOM count)

Banners render the shared `fallback-gradient-avatar.svg` when the
snapshot's image URL doesn't resolve — expected in the local sandbox.

## Preview URL (after CI completes)

`https://comfy-website-preview-pr-{N}.vercel.app/cloud/supported-nodes`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-11903-feat-cloud-nodes-catalog-at-cloud-supported-nodes-3566d73d36508194afdec5f389897585)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-05-12 15:51:24 +00:00

356 lines
9.4 KiB
TypeScript

import { readFile } from 'node:fs/promises'
import {
groupNodesByPack,
sanitizeUserContent,
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type { RegistryPack } from './cloudNodes.registry'
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
import { isNodesSnapshot } from '../data/cloudNodes'
import { fetchRegistryPacks } from './cloudNodes.registry'
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
const DEFAULT_TIMEOUT_MS = 10_000
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000]
export interface DroppedNode {
name: string
reason: string
}
export type FetchOutcome =
| {
status: 'fresh'
snapshot: NodesSnapshot
droppedCount: number
droppedNodes: DroppedNode[]
}
| { status: 'stale'; snapshot: NodesSnapshot; reason: string }
| { status: 'failed'; reason: string }
interface FetchCloudNodesOptions {
apiKey?: string
baseUrl?: string
timeoutMs?: number
retryDelaysMs?: readonly number[]
fetchImpl?: typeof fetch
snapshotUrl?: URL
sleep?: (ms: number) => Promise<void>
}
let inflight: Promise<FetchOutcome> | undefined
let inflightOptions: FetchCloudNodesOptions | undefined
export function resetCloudNodesFetcherForTests(): void {
inflight = undefined
inflightOptions = undefined
}
function optionsDifferMaterially(
a: FetchCloudNodesOptions,
b: FetchCloudNodesOptions
): boolean {
return (
a.apiKey !== b.apiKey ||
a.baseUrl !== b.baseUrl ||
a.timeoutMs !== b.timeoutMs ||
a.snapshotUrl?.href !== b.snapshotUrl?.href
)
}
export function fetchCloudNodesForBuild(
options: FetchCloudNodesOptions = {}
): Promise<FetchOutcome> {
if (inflight && inflightOptions) {
if (optionsDifferMaterially(inflightOptions, options)) {
throw new Error(
'fetchCloudNodesForBuild called twice with different options; call resetCloudNodesFetcherForTests() between distinct configurations'
)
}
return inflight
}
inflightOptions = options
inflight = doFetchCloudNodesForBuild(options)
return inflight
}
async function doFetchCloudNodesForBuild(
options: FetchCloudNodesOptions
): Promise<FetchOutcome> {
const apiKey = options.apiKey ?? process.env.WEBSITE_CLOUD_API_KEY
if (!apiKey) {
return fallback('missing WEBSITE_CLOUD_API_KEY', options.snapshotUrl)
}
const result = await tryFetchAndParse(apiKey, options)
if (result.kind === 'ok') {
return {
status: 'fresh',
snapshot: {
fetchedAt: new Date().toISOString(),
packs: result.packs
},
droppedCount: result.droppedNodes.length,
droppedNodes: result.droppedNodes
}
}
return fallback(result.reason, options.snapshotUrl)
}
async function fallback(
reason: string,
snapshotUrl: URL | undefined
): Promise<FetchOutcome> {
const snapshot = await readSnapshot(snapshotUrl)
if (snapshot) return { status: 'stale', snapshot, reason }
return { status: 'failed', reason }
}
interface FetchOk {
kind: 'ok'
packs: Pack[]
droppedNodes: DroppedNode[]
}
interface FetchErr {
kind: 'err'
reason: string
}
async function tryFetchAndParse(
apiKey: string,
options: FetchCloudNodesOptions
): Promise<FetchOk | FetchErr> {
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS
const retryDelaysMs = options.retryDelaysMs ?? RETRY_DELAYS_MS
const fetchImpl = options.fetchImpl ?? fetch
const sleep = options.sleep ?? defaultSleep
const url = `${baseUrl}/api/object_info`
let lastReason = 'unknown error'
for (let attempt = 0; attempt <= retryDelaysMs.length; attempt++) {
if (attempt > 0) await sleep(retryDelaysMs[attempt - 1])
const response = await callOnce(fetchImpl, url, apiKey, timeoutMs)
if (response.kind === 'err') {
lastReason = response.reason
if (!response.retryable) return response
continue
}
const envelope = CloudNodesEnvelopeSchema.safeParse(response.body)
if (!envelope.success) {
return {
kind: 'err',
reason: `envelope schema validation failed: ${envelope.error.issues
.map((i) => `${i.path.join('.') || '<root>'}: ${i.message}`)
.join('; ')}`
}
}
if (Object.keys(envelope.data).length === 0) {
return {
kind: 'err',
reason:
'envelope schema validation failed: <root>: expected non-empty object'
}
}
return parseCloudNodes(envelope.data, options)
}
return { kind: 'err', reason: lastReason }
}
type CallResponse =
| { kind: 'ok'; body: unknown }
| { kind: 'err'; reason: string; retryable: boolean }
async function callOnce(
fetchImpl: typeof fetch,
url: string,
apiKey: string,
timeoutMs: number
): Promise<CallResponse> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const res = await fetchImpl(url, {
method: 'GET',
headers: {
Accept: 'application/json',
'X-API-Key': apiKey
},
signal: controller.signal
})
if (res.ok) {
return { kind: 'ok', body: await res.json() }
}
const retryable =
res.status === 429 || (res.status >= 500 && res.status < 600)
return {
kind: 'err',
reason: `HTTP ${res.status} ${res.statusText || ''}`.trim(),
retryable
}
} catch (error) {
const reason =
error instanceof Error
? `network error: ${error.message}`
: 'network error'
return { kind: 'err', reason, retryable: true }
} finally {
clearTimeout(timer)
}
}
async function parseCloudNodes(
envelope: Record<string, unknown>,
options: FetchCloudNodesOptions
): Promise<FetchOk> {
const validDefs: Record<string, ReturnType<typeof validateComfyNodeDef>> = {}
const droppedNodes: DroppedNode[] = []
for (const [name, rawDef] of Object.entries(envelope)) {
let validationError = 'unknown validation error'
const parsed = validateComfyNodeDef(rawDef, (error) => {
validationError = error
})
if (!parsed) {
droppedNodes.push({ name, reason: validationError })
continue
}
validDefs[name] = parsed
}
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
const grouped = groupNodesByPack(sanitizedDefs)
let registryMap = new Map<string, RegistryPack | null>()
try {
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
} catch {
registryMap = new Map()
}
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
return { kind: 'ok', packs, droppedNodes }
}
function safeExternalUrl(value: string | undefined): string | undefined {
if (!value) return undefined
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return undefined
if (!url.host) return undefined
return url.toString()
} catch {
return undefined
}
}
function toDomainPack(
packId: string,
fallbackDisplayName: string,
nodes: Array<{
className: string
def: {
display_name: string
category: string
description: string
deprecated?: boolean
experimental?: boolean
}
}>,
registryPack: RegistryPack | null | undefined
): Pack {
return {
id: packId,
registryId: registryPack?.id,
displayName: registryPack?.name?.trim() || fallbackDisplayName || packId,
description: registryPack?.description?.trim() || undefined,
bannerUrl: safeExternalUrl(registryPack?.banner_url),
iconUrl: safeExternalUrl(registryPack?.icon),
repoUrl: safeExternalUrl(registryPack?.repository),
publisher: registryPack?.publisher?.id
? {
id: registryPack.publisher.id,
name: registryPack.publisher.name
}
: undefined,
downloads: registryPack?.downloads,
githubStars: registryPack?.github_stars,
latestVersion: registryPack?.latest_version?.version,
license: registryPack?.license,
lastUpdated:
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
supportedOs: registryPack?.supported_os,
supportedAccelerators: registryPack?.supported_accelerators,
nodes: nodes
.map((node) => toDomainNode(node.className, node.def))
.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}
function toDomainNode(
className: string,
def: {
display_name: string
category: string
description: string
deprecated?: boolean
experimental?: boolean
}
): PackNode {
return {
name: className,
displayName: def.display_name,
category: def.category,
description: def.description || undefined,
deprecated: def.deprecated,
experimental: def.experimental
}
}
async function readSnapshot(
snapshotUrl: URL | undefined
): Promise<NodesSnapshot | null> {
if (!snapshotUrl) {
return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null
}
try {
const text = await readFile(snapshotUrl, 'utf8')
const parsed: unknown = JSON.parse(text)
if (isNodesSnapshot(parsed)) return parsed
return null
} catch {
return null
}
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}