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
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>
310 lines
9.2 KiB
TypeScript
310 lines
9.2 KiB
TypeScript
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import type { NodesSnapshot } from '../data/cloudNodes'
|
|
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
|
|
|
|
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
|
|
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
|
|
|
|
vi.mock('./cloudNodes.registry', () => ({
|
|
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
|
|
fetchRegistryPacks: fetchRegistryPacksMock
|
|
}))
|
|
|
|
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
|
|
const actual = (await importOriginal()) as typeof ObjectInfoParser
|
|
return {
|
|
...actual,
|
|
sanitizeUserContent: (
|
|
defs: Parameters<typeof actual.sanitizeUserContent>[0]
|
|
) => {
|
|
sanitizeCallSpy(defs)
|
|
return actual.sanitizeUserContent(defs)
|
|
}
|
|
}
|
|
})
|
|
|
|
import {
|
|
fetchCloudNodesForBuild,
|
|
resetCloudNodesFetcherForTests
|
|
} from './cloudNodes'
|
|
|
|
const BASE_URL = 'https://cloud.test'
|
|
const KEY = 'cloud-secret'
|
|
|
|
function validNode(
|
|
overrides: Partial<Record<string, unknown>> = {}
|
|
): Record<string, unknown> {
|
|
return {
|
|
name: 'ImpactNode',
|
|
display_name: 'Impact Node',
|
|
description: 'Node description',
|
|
category: 'impact/testing',
|
|
output_node: false,
|
|
python_module: 'custom_nodes.comfyui-impact-pack.nodes',
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
function response(body: unknown, init: Partial<ResponseInit> = {}): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status: 200,
|
|
headers: { 'content-type': 'application/json' },
|
|
...init
|
|
})
|
|
}
|
|
|
|
function makeSnapshot(packCount = 1): NodesSnapshot {
|
|
const packs = Array.from({ length: packCount }, (_, i) => ({
|
|
id: `snapshot-pack-${i}`,
|
|
displayName: `Snapshot Pack ${i}`,
|
|
nodes: [
|
|
{
|
|
name: `SnapshotNode${i}`,
|
|
displayName: `Snapshot Node ${i}`,
|
|
category: 'snapshot'
|
|
}
|
|
]
|
|
}))
|
|
|
|
return {
|
|
fetchedAt: '2026-04-01T00:00:00.000Z',
|
|
packs
|
|
}
|
|
}
|
|
|
|
function withSnapshotDir(snapshot: NodesSnapshot | null): URL {
|
|
const dir = mkdtempSync(join(tmpdir(), 'cloud-nodes-test-'))
|
|
const file = join(dir, 'cloud-nodes.snapshot.json')
|
|
if (snapshot) writeFileSync(file, JSON.stringify(snapshot))
|
|
return pathToFileURL(file)
|
|
}
|
|
|
|
describe('fetchCloudNodesForBuild', () => {
|
|
const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY
|
|
|
|
beforeEach(() => {
|
|
resetCloudNodesFetcherForTests()
|
|
fetchRegistryPacksMock.mockReset()
|
|
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
|
sanitizeCallSpy.mockReset()
|
|
delete process.env.WEBSITE_CLOUD_API_KEY
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey
|
|
})
|
|
|
|
it('returns fresh when API succeeds', async () => {
|
|
fetchRegistryPacksMock.mockResolvedValue(
|
|
new Map([
|
|
[
|
|
'comfyui-impact-pack',
|
|
{
|
|
id: 'comfyui-impact-pack',
|
|
name: 'ComfyUI Impact Pack',
|
|
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
|
}
|
|
]
|
|
])
|
|
)
|
|
|
|
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(outcome.status).toBe('fresh')
|
|
if (outcome.status !== 'fresh') return
|
|
expect(outcome.droppedCount).toBe(0)
|
|
expect(outcome.snapshot.packs).toHaveLength(1)
|
|
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
|
|
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
|
|
)
|
|
})
|
|
|
|
it('drops invalid nodes individually and keeps valid nodes', async () => {
|
|
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
|
const fetchImpl = vi.fn(async () =>
|
|
response({
|
|
ValidNode: validNode({ name: 'ValidNode' }),
|
|
BrokenNode: {
|
|
name: 'BrokenNode',
|
|
python_module: 'custom_nodes.some-pack'
|
|
}
|
|
})
|
|
)
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
snapshotUrl,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(outcome.status).toBe('fresh')
|
|
if (outcome.status !== 'fresh') return
|
|
expect(outcome.droppedCount).toBe(1)
|
|
expect(outcome.droppedNodes[0]?.name).toBe('BrokenNode')
|
|
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(1)
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('applies sanitizer before grouping', async () => {
|
|
const fetchImpl = vi.fn(async () =>
|
|
response({
|
|
LoadImage: validNode({
|
|
name: 'LoadImage',
|
|
python_module: 'nodes',
|
|
input: {
|
|
required: {
|
|
image: [['private.png', 'public.webp'], {}]
|
|
}
|
|
}
|
|
}),
|
|
ImpactNode: validNode({
|
|
input: {
|
|
required: {
|
|
choice: [['safe', 'movie.mov'], {}]
|
|
}
|
|
}
|
|
})
|
|
})
|
|
)
|
|
|
|
await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(sanitizeCallSpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('returns stale with missing env when snapshot is present', async () => {
|
|
const snapshot = makeSnapshot()
|
|
const snapshotUrl = withSnapshotDir(snapshot)
|
|
const fetchImpl = vi.fn()
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
snapshotUrl,
|
|
fetchImpl: fetchImpl as unknown as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
if (outcome.status !== 'stale') return
|
|
expect(outcome.reason).toMatch(/^missing /)
|
|
expect(fetchImpl).not.toHaveBeenCalled()
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('returns failed when env and snapshot are missing', async () => {
|
|
const snapshotUrl = withSnapshotDir(null)
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
snapshotUrl,
|
|
fetchImpl: vi.fn() as unknown as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('failed')
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('does not retry on HTTP 401', async () => {
|
|
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
|
const fetchImpl = vi.fn(async () => response({}, { status: 401 }))
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
snapshotUrl,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
if (outcome.status !== 'stale') return
|
|
expect(outcome.reason).toMatch(/^HTTP 401/)
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('retries 5xx then falls back to snapshot', async () => {
|
|
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
|
const fetchImpl = vi.fn(async () => response({}, { status: 503 }))
|
|
const sleep = vi.fn(async () => undefined)
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
snapshotUrl,
|
|
retryDelaysMs: [1, 1, 1],
|
|
sleep,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
expect(fetchImpl).toHaveBeenCalledTimes(4)
|
|
expect(sleep).toHaveBeenCalledTimes(3)
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('falls back to snapshot on envelope schema mismatch', async () => {
|
|
const snapshotUrl = withSnapshotDir(makeSnapshot())
|
|
const fetchImpl = vi.fn(async () => response(['unexpected-array-envelope']))
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
snapshotUrl,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('stale')
|
|
if (outcome.status !== 'stale') return
|
|
expect(outcome.reason).toMatch(/^envelope schema/)
|
|
rmSync(new URL('.', snapshotUrl), { recursive: true, force: true })
|
|
})
|
|
|
|
it('memoizes within a single process', async () => {
|
|
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
|
const opts = {
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
}
|
|
|
|
const [a, b] = await Promise.all([
|
|
fetchCloudNodesForBuild(opts),
|
|
fetchCloudNodesForBuild(opts)
|
|
])
|
|
|
|
expect(a).toBe(b)
|
|
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('throws when called twice with materially different options', async () => {
|
|
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
|
await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
|
|
expect(() =>
|
|
fetchCloudNodesForBuild({
|
|
apiKey: 'different-key',
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
).toThrow(/called twice with different options/)
|
|
})
|
|
|
|
it('returns fresh even when registry enrichment fails', async () => {
|
|
fetchRegistryPacksMock.mockResolvedValue(new Map())
|
|
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
|
|
const outcome = await fetchCloudNodesForBuild({
|
|
apiKey: KEY,
|
|
baseUrl: BASE_URL,
|
|
fetchImpl: fetchImpl as typeof fetch
|
|
})
|
|
expect(outcome.status).toBe('fresh')
|
|
})
|
|
})
|