diff --git a/.github/workflows/ci-website-e2e.yaml b/.github/workflows/ci-website-e2e.yaml index ea8e7f0592c..636e7598935 100644 --- a/.github/workflows/ci-website-e2e.yaml +++ b/.github/workflows/ci-website-e2e.yaml @@ -51,6 +51,7 @@ jobs: - name: Build website env: WEBSITE_GITHUB_STARS_OVERRIDE: 110000 + WEBSITE_CLOUD_NODES_FIXTURE: e2e/fixtures/cloud-nodes.fixture.json run: pnpm --filter @comfyorg/website build - name: Run Playwright tests diff --git a/apps/website/e2e/fixtures/cloud-nodes.fixture.json b/apps/website/e2e/fixtures/cloud-nodes.fixture.json new file mode 100644 index 00000000000..231ae804896 --- /dev/null +++ b/apps/website/e2e/fixtures/cloud-nodes.fixture.json @@ -0,0 +1,156 @@ +{ + "fetchedAt": "2026-01-01T00:00:00.000Z", + "packs": [ + { + "id": "comfyui-impact-pack", + "registryId": "comfyui-impact-pack", + "displayName": "ComfyUI Impact Pack", + "description": "Production-grade detailer, detector, and SEG (segmentation) tooling. The most-used pack for face restoration, region-based refinement, and iterative upscaling on Comfy Cloud.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-banner.webp", + "iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-impact-pack-icon.webp", + "repoUrl": "https://github.com/ltdrdata/ComfyUI-Impact-Pack", + "publisher": { + "id": "drltdata", + "name": "Dr.Lt.Data" + }, + "downloads": 2618646, + "githubStars": 3092, + "latestVersion": "8.28.3", + "license": "GPL-3.0", + "lastUpdated": "2026-04-19T17:08:04.993918Z", + "nodes": [ + { + "name": "FaceDetailer", + "displayName": "FaceDetailer", + "category": "ImpactPack/Detailer", + "description": "Detect and refine faces with iterative passes." + }, + { + "name": "DetailerForEach", + "displayName": "DetailerForEach", + "category": "ImpactPack/Detailer", + "description": "Run iterative detail refinement over detected SEG regions." + }, + { + "name": "UltralyticsDetectorProvider", + "displayName": "UltralyticsDetectorProvider", + "category": "ImpactPack/Detector", + "description": "Provide detector models powered by Ultralytics YOLO." + } + ] + }, + { + "id": "comfyui-crystools", + "registryId": "ComfyUI-Crystools", + "displayName": "ComfyUI-Crystools", + "description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-banner.webp", + "iconUrl": "https://media.comfy.org/cloud-nodes/comfyui-crystools-icon.webp", + "repoUrl": "https://github.com/crystian/ComfyUI-Crystools", + "publisher": { + "id": "crystian", + "name": "Crystian" + }, + "downloads": 1671447, + "githubStars": 1855, + "latestVersion": "1.27.4", + "license": "MIT", + "lastUpdated": "2025-10-26T19:11:09.943366Z", + "nodes": [ + { + "name": "CCrystools_Show_Resources", + "displayName": "CCrystools_Show_Resources", + "category": "crystools/show", + "description": "Display GPU, RAM and disk usage live in the workflow." + }, + { + "name": "CCrystools_Show_Image", + "displayName": "CCrystools_Show_Image", + "category": "crystools/show", + "description": "Inspect images at full resolution with metadata overlays." + } + ] + }, + { + "id": "alpha-test-pack", + "registryId": "alpha-test-pack", + "displayName": "Alpha Test Pack", + "description": "Deterministic fixture pack used to anchor the A\u2192Z sort assertion in Playwright. Its display name starts with 'A' so it surfaces first when sorted alphabetically.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/alpha-test-pack-banner.webp", + "repoUrl": "https://github.com/Comfy-Org/alpha-test-pack", + "publisher": { + "id": "comfy-org", + "name": "Comfy Org" + }, + "downloads": 42, + "githubStars": 7, + "latestVersion": "0.1.0", + "license": "MIT", + "lastUpdated": "2026-01-01T00:00:00.000Z", + "nodes": [ + { + "name": "AlphaProbe", + "displayName": "AlphaProbe", + "category": "alpha/test", + "description": "Deterministic node used to verify alphabetical ordering." + } + ] + }, + { + "id": "rgthree-comfy", + "registryId": "rgthree-comfy", + "displayName": "rgthree-comfy", + "description": "Quality-of-life nodes from rgthree: Power Lora Loader, Display Any, Image Comparer, and more.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/rgthree-comfy-banner.webp", + "repoUrl": "https://github.com/rgthree/rgthree-comfy", + "publisher": { + "id": "rgthree", + "name": "rgthree" + }, + "downloads": 987654, + "githubStars": 1500, + "latestVersion": "2.0.0", + "license": "MIT", + "lastUpdated": "2026-03-15T00:00:00.000Z", + "nodes": [ + { + "name": "PowerLoraLoader", + "displayName": "Power Lora Loader", + "category": "rgthree/loaders", + "description": "Load multiple LoRAs at once with strength control." + }, + { + "name": "DisplayAny", + "displayName": "Display Any", + "category": "rgthree/display", + "description": "Display the value of any wire for debugging." + } + ] + }, + { + "id": "was-node-suite-comfyui", + "registryId": "was-node-suite-comfyui", + "displayName": "WAS Node Suite", + "description": "Large collection of utility nodes for image processing, text manipulation, and workflow control.", + "bannerUrl": "https://media.comfy.org/cloud-nodes/was-node-suite-banner.webp", + "repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui", + "publisher": { + "id": "wasasquatch", + "name": "WASasquatch" + }, + "downloads": 1234567, + "githubStars": 1700, + "latestVersion": "1.0.0", + "license": "MIT", + "lastUpdated": "2026-02-01T00:00:00.000Z", + "nodes": [ + { + "name": "WASImageBlend", + "displayName": "Image Blend", + "category": "WAS/image", + "description": "Blend two images using a configurable mode." + } + ] + } + ] +} diff --git a/apps/website/src/data/cloud-nodes.snapshot.json b/apps/website/src/data/cloud-nodes.snapshot.json index a5a85565dbc..60842da2fff 100644 --- a/apps/website/src/data/cloud-nodes.snapshot.json +++ b/apps/website/src/data/cloud-nodes.snapshot.json @@ -50,7 +50,7 @@ ] }, { - "id": "ComfyUI-Crystools", + "id": "comfyui-crystools", "registryId": "ComfyUI-Crystools", "displayName": "ComfyUI-Crystools", "description": "Live system monitoring (GPU, RAM, disk) and rich image inspection inside your workflow. The most-installed quality-of-life pack on the registry.", @@ -201,7 +201,7 @@ "id": "comfyui-easy-use", "registryId": "comfyui-easy-use", "displayName": "ComfyUI-Easy-Use", - "description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins — full loader, pre-sampling, easy KSampler, and XY plotting.", + "description": "Simplified, opinionated nodes that bundle common patterns into single drop-ins \u2014 full loader, pre-sampling, easy KSampler, and XY plotting.", "iconUrl": "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg", "repoUrl": "https://github.com/yolain/ComfyUI-Easy-Use", "publisher": { @@ -250,7 +250,7 @@ "id": "comfyui-advanced-controlnet", "registryId": "comfyui-advanced-controlnet", "displayName": "ComfyUI-Advanced-ControlNet", - "description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling — essential for animation and batched-latent workflows.", + "description": "ControlNet with timestep keyframes, per-frame masks, and advanced strength scheduling \u2014 essential for animation and batched-latent workflows.", "repoUrl": "https://github.com/Kosinkadink/ComfyUI-Advanced-ControlNet", "publisher": { "id": "kosinkadink", @@ -298,7 +298,7 @@ "id": "was-node-suite-comfyui", "registryId": "was-node-suite-comfyui", "displayName": "WAS Node Suite", - "description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O — the original \"kitchen sink\" pack still relied on by thousands of workflows.", + "description": "A broad utility suite covering image adjustments, compositing, text, math, and I/O \u2014 the original \"kitchen sink\" pack still relied on by thousands of workflows.", "repoUrl": "https://github.com/WASasquatch/was-node-suite-comfyui", "publisher": { "id": "was", @@ -343,10 +343,10 @@ ] }, { - "id": "comfyui_ipadapter_plus", + "id": "comfyui-ipadapter-plus", "registryId": "comfyui_ipadapter_plus", "displayName": "ComfyUI_IPAdapter_plus", - "description": "Reference-image conditioning with IPAdapter — style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.", + "description": "Reference-image conditioning with IPAdapter \u2014 style transfer, Face ID, and multi-image embeddings. The most-installed conditioning pack on the registry, used in countless portrait, product, and animation workflows.", "repoUrl": "https://github.com/cubiq/ComfyUI_IPAdapter_plus", "publisher": { "id": "matteo", diff --git a/apps/website/src/utils/cloudNodes.build.test.ts b/apps/website/src/utils/cloudNodes.build.test.ts index 0359987e192..a953cb4a2e6 100644 --- a/apps/website/src/utils/cloudNodes.build.test.ts +++ b/apps/website/src/utils/cloudNodes.build.test.ts @@ -4,7 +4,9 @@ import type { FetchOutcome } from './cloudNodes' import type { NodesSnapshot } from '../data/cloudNodes' const fetchCloudNodesMock = vi.hoisted(() => - vi.fn<() => Promise>() + vi.fn< + (options?: { snapshotUrl?: URL; apiKey?: string }) => Promise + >() ) const reportCloudNodesOutcomeMock = vi.hoisted(() => vi.fn()) @@ -33,19 +35,26 @@ const SNAPSHOT: NodesSnapshot = { describe('loadPacksForBuild', () => { const savedVercelEnv = process.env.VERCEL_ENV + const savedFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE beforeEach(() => { fetchCloudNodesMock.mockReset() reportCloudNodesOutcomeMock.mockReset() delete process.env.VERCEL_ENV + delete process.env.WEBSITE_CLOUD_NODES_FIXTURE }) afterEach(() => { if (savedVercelEnv === undefined) { delete process.env.VERCEL_ENV - return + } else { + process.env.VERCEL_ENV = savedVercelEnv + } + if (savedFixture === undefined) { + delete process.env.WEBSITE_CLOUD_NODES_FIXTURE + } else { + process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedFixture } - process.env.VERCEL_ENV = savedVercelEnv }) it('returns packs when fetch is fresh', async () => { @@ -125,4 +134,50 @@ describe('loadPacksForBuild', () => { await expect(loadPacksForBuild()).rejects.toThrow() expect(reportCloudNodesOutcomeMock).toHaveBeenCalledTimes(1) }) + + it('forwards WEBSITE_CLOUD_NODES_FIXTURE as snapshotUrl with an empty api key', async () => { + process.env.WEBSITE_CLOUD_NODES_FIXTURE = + 'e2e/fixtures/cloud-nodes.fixture.json' + fetchCloudNodesMock.mockResolvedValue({ + status: 'stale', + snapshot: SNAPSHOT, + reason: 'missing WEBSITE_CLOUD_API_KEY' + }) + + await loadPacksForBuild() + const call = fetchCloudNodesMock.mock.calls[0]?.[0] + expect(call?.snapshotUrl).toBeInstanceOf(URL) + expect(call?.snapshotUrl?.protocol).toBe('file:') + expect(call?.snapshotUrl?.pathname).toMatch( + /apps\/website\/e2e\/fixtures\/cloud-nodes\.fixture\.json$/ + ) + expect(call?.apiKey).toBe('') + }) + + it('accepts an absolute path for WEBSITE_CLOUD_NODES_FIXTURE', async () => { + process.env.WEBSITE_CLOUD_NODES_FIXTURE = '/etc/cloud-nodes.fixture.json' + fetchCloudNodesMock.mockResolvedValue({ + status: 'stale', + snapshot: SNAPSHOT, + reason: 'missing WEBSITE_CLOUD_API_KEY' + }) + + await loadPacksForBuild() + const call = fetchCloudNodesMock.mock.calls[0]?.[0] + expect(call?.snapshotUrl?.pathname).toBe('/etc/cloud-nodes.fixture.json') + }) + + it('does not throw on stale-in-production when the fixture override is set', async () => { + process.env.VERCEL_ENV = 'production' + process.env.WEBSITE_CLOUD_NODES_FIXTURE = + 'e2e/fixtures/cloud-nodes.fixture.json' + fetchCloudNodesMock.mockResolvedValue({ + status: 'stale', + snapshot: SNAPSHOT, + reason: 'missing WEBSITE_CLOUD_API_KEY' + }) + + const packs = await loadPacksForBuild() + expect(packs).toBe(SNAPSHOT.packs) + }) }) diff --git a/apps/website/src/utils/cloudNodes.build.ts b/apps/website/src/utils/cloudNodes.build.ts index 12ae56828d6..0c6fb5f5e65 100644 --- a/apps/website/src/utils/cloudNodes.build.ts +++ b/apps/website/src/utils/cloudNodes.build.ts @@ -1,3 +1,6 @@ +import { isAbsolute, resolve as resolvePath } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + import type { Pack } from '../data/cloudNodes' import { fetchCloudNodesForBuild } from './cloudNodes' @@ -7,10 +10,21 @@ const REFRESH_HINT = 'Run `pnpm --filter @comfyorg/website cloud-nodes:refresh-snapshot` locally and commit the snapshot, ' + 'or re-run the `Release: Website` workflow with a valid WEBSITE_CLOUD_API_KEY.' +const WEBSITE_PACKAGE_ROOT = fileURLToPath(new URL('../..', import.meta.url)) + function isProductionBuild(): boolean { return process.env.VERCEL_ENV === 'production' } +function fixtureSnapshotUrl(): URL | undefined { + const fixturePath = process.env.WEBSITE_CLOUD_NODES_FIXTURE + if (!fixturePath) return undefined + const absolute = isAbsolute(fixturePath) + ? fixturePath + : resolvePath(WEBSITE_PACKAGE_ROOT, fixturePath) + return pathToFileURL(absolute) +} + /** * Resolve the list of packs to render at build time. * @@ -23,9 +37,18 @@ function isProductionBuild(): boolean { * Production builds (VERCEL_ENV=production) fail hard on a stale outcome * to prevent silently shipping out-of-date snapshot data. Preview and * local builds continue to use the committed snapshot. + * + * Setting `WEBSITE_CLOUD_NODES_FIXTURE=` overrides the bundled + * snapshot with a fixture file on disk. This is used by the e2e build + * step in CI so Playwright assertions can be written against deterministic + * pack content instead of whatever the upstream registry happens to expose + * at the moment of the test run. The override never fires the live cloud + * API; the fixture path goes straight to the snapshot-fallback branch. */ export async function loadPacksForBuild(): Promise { - const outcome = await fetchCloudNodesForBuild() + const snapshotUrl = fixtureSnapshotUrl() + const options = snapshotUrl ? { snapshotUrl, apiKey: '' } : {} + const outcome = await fetchCloudNodesForBuild(options) reportCloudNodesOutcome(outcome) if (outcome.status === 'failed') { @@ -34,7 +57,7 @@ export async function loadPacksForBuild(): Promise { ) } - if (outcome.status === 'stale' && isProductionBuild()) { + if (outcome.status === 'stale' && isProductionBuild() && !snapshotUrl) { throw new Error( `Cloud nodes fetch returned stale data in a production build (VERCEL_ENV=production). ` + `Reason: ${outcome.reason}. ${REFRESH_HINT}` diff --git a/apps/website/src/utils/cloudNodes.test.ts b/apps/website/src/utils/cloudNodes.test.ts index 83b8939911d..7db475eac09 100644 --- a/apps/website/src/utils/cloudNodes.test.ts +++ b/apps/website/src/utils/cloudNodes.test.ts @@ -87,6 +87,7 @@ function withSnapshotDir(snapshot: NodesSnapshot | null): URL { describe('fetchCloudNodesForBuild', () => { const savedCloudApiKey = process.env.WEBSITE_CLOUD_API_KEY + const savedCloudNodesFixture = process.env.WEBSITE_CLOUD_NODES_FIXTURE beforeEach(() => { resetCloudNodesFetcherForTests() @@ -94,11 +95,21 @@ describe('fetchCloudNodesForBuild', () => { fetchRegistryPacksMock.mockResolvedValue(new Map()) sanitizeCallSpy.mockReset() delete process.env.WEBSITE_CLOUD_API_KEY + delete process.env.WEBSITE_CLOUD_NODES_FIXTURE }) afterEach(() => { vi.restoreAllMocks() - process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey + if (savedCloudApiKey === undefined) { + delete process.env.WEBSITE_CLOUD_API_KEY + } else { + process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey + } + if (savedCloudNodesFixture === undefined) { + delete process.env.WEBSITE_CLOUD_NODES_FIXTURE + } else { + process.env.WEBSITE_CLOUD_NODES_FIXTURE = savedCloudNodesFixture + } }) it('returns fresh when API succeeds', async () => { @@ -306,4 +317,335 @@ describe('fetchCloudNodesForBuild', () => { }) expect(outcome.status).toBe('fresh') }) + + it('falls back to the raw upstream id for registryId when registry lookup misses', async () => { + fetchRegistryPacksMock.mockResolvedValue(new Map()) + const fetchImpl = vi.fn(async () => + response({ + QwenNode: validNode({ + name: 'QwenNode', + python_module: 'custom_nodes.ComfyUI_QwenVL.nodes' + }) + }) + ) + 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.snapshot.packs[0]?.id).toBe('comfyui-qwenvl') + expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL') + }) + + it('falls back to the raw upstream id for registryId when fetchRegistryPacks throws', async () => { + fetchRegistryPacksMock.mockImplementation(async () => { + throw new Error('registry unreachable') + }) + const fetchImpl = vi.fn(async () => + response({ + QwenNode: validNode({ + name: 'QwenNode', + python_module: 'custom_nodes.ComfyUI_QwenVL.nodes' + }) + }) + ) + 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.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL') + }) + + it('slugifies pack ids while querying the registry with the raw id', async () => { + fetchRegistryPacksMock.mockResolvedValue( + new Map([ + [ + 'ComfyUI_QwenVL', + { + id: 'ComfyUI_QwenVL', + name: 'ComfyUI QwenVL', + repository: 'https://github.com/example/ComfyUI_QwenVL' + } + ] + ]) + ) + + const fetchImpl = vi.fn(async () => + response({ + QwenNode: validNode({ + name: 'QwenNode', + python_module: 'custom_nodes.ComfyUI_QwenVL.nodes' + }) + }) + ) + 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.snapshot.packs[0]?.id).toBe('comfyui-qwenvl') + expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL') + expect(fetchRegistryPacksMock).toHaveBeenCalledWith( + ['ComfyUI_QwenVL'], + expect.anything() + ) + }) + + it('queries every raw-id alias when packs collide on the same slug and picks the first hit', async () => { + fetchRegistryPacksMock.mockResolvedValue( + new Map([ + ['ComfyUI-QwenVL', null], + [ + 'ComfyUI_QwenVL', + { + id: 'ComfyUI_QwenVL', + name: 'ComfyUI QwenVL', + repository: 'https://github.com/example/ComfyUI_QwenVL' + } + ] + ]) + ) + + const fetchImpl = vi.fn(async () => + response({ + QwenDash: validNode({ + name: 'QwenDash', + python_module: 'custom_nodes.ComfyUI-QwenVL.nodes' + }), + QwenUnder: validNode({ + name: 'QwenUnder', + python_module: 'custom_nodes.ComfyUI_QwenVL.nodes' + }) + }) + ) + 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.snapshot.packs).toHaveLength(1) + expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl') + expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI_QwenVL') + expect(outcome.snapshot.packs[0]?.repoUrl).toBe( + 'https://github.com/example/ComfyUI_QwenVL' + ) + expect(fetchRegistryPacksMock).toHaveBeenCalledWith( + ['ComfyUI-QwenVL', 'ComfyUI_QwenVL'], + expect.anything() + ) + }) + + it('prefers the first non-null registry result when every alias resolves', async () => { + fetchRegistryPacksMock.mockResolvedValue( + new Map([ + [ + 'ComfyUI-QwenVL', + { + id: 'ComfyUI-QwenVL', + name: 'Dash Variant', + repository: 'https://github.com/example/dash-first' + } + ], + [ + 'ComfyUI_QwenVL', + { + id: 'ComfyUI_QwenVL', + name: 'Underscore Variant', + repository: 'https://github.com/example/underscore-second' + } + ] + ]) + ) + + const fetchImpl = vi.fn(async () => + response({ + QwenDash: validNode({ + name: 'QwenDash', + python_module: 'custom_nodes.ComfyUI-QwenVL.nodes' + }), + QwenUnder: validNode({ + name: 'QwenUnder', + python_module: 'custom_nodes.ComfyUI_QwenVL.nodes' + }) + }) + ) + 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.snapshot.packs).toHaveLength(1) + expect(outcome.snapshot.packs[0]?.registryId).toBe('ComfyUI-QwenVL') + expect(outcome.snapshot.packs[0]?.repoUrl).toBe( + 'https://github.com/example/dash-first' + ) + expect(fetchRegistryPacksMock).toHaveBeenCalledWith( + ['ComfyUI-QwenVL', 'ComfyUI_QwenVL'], + expect.anything() + ) + }) + + it('normalizes pack ids when reading a fallback snapshot', async () => { + const snapshotUrl = withSnapshotDir({ + fetchedAt: '2026-04-01T00:00:00.000Z', + packs: [ + { + id: 'ComfyUI-Crystools', + displayName: 'ComfyUI-Crystools', + nodes: [ + { + name: 'CrystoolsNode', + displayName: 'Crystools Node', + category: 'x' + } + ] + }, + { + id: 'basic_data_handling', + displayName: 'basic_data_handling', + nodes: [ + { name: 'BasicNode', displayName: 'Basic Node', category: 'x' } + ] + } + ] + }) + + const outcome = await fetchCloudNodesForBuild({ + snapshotUrl, + fetchImpl: vi.fn() as unknown as typeof fetch + }) + expect(outcome.status).toBe('stale') + if (outcome.status !== 'stale') return + expect(outcome.snapshot.packs.map((p) => p.id)).toEqual([ + 'comfyui-crystools', + 'basic-data-handling' + ]) + rmSync(new URL('.', snapshotUrl), { recursive: true, force: true }) + }) + + it('merges packs in the fallback snapshot whose ids slugify to the same value', async () => { + const snapshotUrl = withSnapshotDir({ + fetchedAt: '2026-04-01T00:00:00.000Z', + packs: [ + { + id: 'ComfyUI-QwenVL', + displayName: 'ComfyUI QwenVL', + nodes: [{ name: 'A', displayName: 'A', category: 'x' }] + }, + { + id: 'ComfyUI_QwenVL', + displayName: 'ComfyUI QwenVL', + nodes: [{ name: 'B', displayName: 'B', category: 'x' }] + } + ] + }) + + const outcome = await fetchCloudNodesForBuild({ + snapshotUrl, + fetchImpl: vi.fn() as unknown as typeof fetch + }) + expect(outcome.status).toBe('stale') + if (outcome.status !== 'stale') return + expect(outcome.snapshot.packs).toHaveLength(1) + expect(outcome.snapshot.packs[0]?.id).toBe('comfyui-qwenvl') + expect(outcome.snapshot.packs[0]?.nodes.map((n) => n.name).sort()).toEqual([ + 'A', + 'B' + ]) + rmSync(new URL('.', snapshotUrl), { recursive: true, force: true }) + }) + + it('preserves optional metadata from later aliases when snapshot packs collide on slug', async () => { + const snapshotUrl = withSnapshotDir({ + fetchedAt: '2026-04-01T00:00:00.000Z', + packs: [ + { + id: 'ComfyUI-QwenVL', + displayName: 'ComfyUI QwenVL', + nodes: [{ name: 'A', displayName: 'A', category: 'x' }] + }, + { + id: 'ComfyUI_QwenVL', + displayName: 'ComfyUI QwenVL', + registryId: 'ComfyUI_QwenVL', + description: 'rich description from the underscore variant', + repoUrl: 'https://github.com/example/ComfyUI_QwenVL', + publisher: { id: 'qwen-team', name: 'Qwen Team' }, + downloads: 1234, + githubStars: 7, + license: 'MIT', + nodes: [{ name: 'B', displayName: 'B', category: 'x' }] + } + ] + }) + + const outcome = await fetchCloudNodesForBuild({ + snapshotUrl, + fetchImpl: vi.fn() as unknown as typeof fetch + }) + expect(outcome.status).toBe('stale') + if (outcome.status !== 'stale') return + const merged = outcome.snapshot.packs[0] + expect(merged?.id).toBe('comfyui-qwenvl') + expect(merged?.registryId).toBe('ComfyUI_QwenVL') + expect(merged?.description).toBe( + 'rich description from the underscore variant' + ) + expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI_QwenVL') + expect(merged?.publisher).toEqual({ id: 'qwen-team', name: 'Qwen Team' }) + expect(merged?.downloads).toBe(1234) + expect(merged?.githubStars).toBe(7) + expect(merged?.license).toBe('MIT') + rmSync(new URL('.', snapshotUrl), { recursive: true, force: true }) + }) + + it('does not overwrite metadata already present on the first slug-collided pack', async () => { + const snapshotUrl = withSnapshotDir({ + fetchedAt: '2026-04-01T00:00:00.000Z', + packs: [ + { + id: 'ComfyUI-QwenVL', + displayName: 'first wins', + registryId: 'ComfyUI-QwenVL', + repoUrl: 'https://github.com/example/ComfyUI-QwenVL', + nodes: [{ name: 'A', displayName: 'A', category: 'x' }] + }, + { + id: 'ComfyUI_QwenVL', + displayName: 'second loses', + registryId: 'ComfyUI_QwenVL', + repoUrl: 'https://github.com/example/ComfyUI_QwenVL', + nodes: [{ name: 'B', displayName: 'B', category: 'x' }] + } + ] + }) + + const outcome = await fetchCloudNodesForBuild({ + snapshotUrl, + fetchImpl: vi.fn() as unknown as typeof fetch + }) + expect(outcome.status).toBe('stale') + if (outcome.status !== 'stale') return + const merged = outcome.snapshot.packs[0] + expect(merged?.displayName).toBe('first wins') + expect(merged?.registryId).toBe('ComfyUI-QwenVL') + expect(merged?.repoUrl).toBe('https://github.com/example/ComfyUI-QwenVL') + rmSync(new URL('.', snapshotUrl), { recursive: true, force: true }) + }) }) diff --git a/apps/website/src/utils/cloudNodes.ts b/apps/website/src/utils/cloudNodes.ts index 2ad97a966e6..60e0f606056 100644 --- a/apps/website/src/utils/cloudNodes.ts +++ b/apps/website/src/utils/cloudNodes.ts @@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises' import { groupNodesByPack, sanitizeUserContent, + slugifyPackId, validateComfyNodeDef } from '@comfyorg/object-info-parser' @@ -237,12 +238,12 @@ async function parseCloudNodes( ) const grouped = groupNodesByPack(sanitizedDefs) + const allAliases = grouped.flatMap((pack) => pack.rawIds) let registryMap = new Map() try { - registryMap = await fetchRegistryPacks( - grouped.map((pack) => pack.id), - { fetchImpl: options.fetchImpl } - ) + registryMap = await fetchRegistryPacks(allAliases, { + fetchImpl: options.fetchImpl + }) } catch { registryMap = new Map() } @@ -250,15 +251,27 @@ async function parseCloudNodes( const packs = grouped.map((pack) => toDomainPack( pack.id, + pack.rawIds[0], pack.displayName, pack.nodes, - registryMap.get(pack.id) + pickRegistryPack(registryMap, pack.rawIds) ) ) return { kind: 'ok', packs, droppedNodes } } +function pickRegistryPack( + registryMap: Map, + aliases: readonly string[] +): RegistryPack | null | undefined { + for (const alias of aliases) { + const hit = registryMap.get(alias) + if (hit) return hit + } + return registryMap.get(aliases[0]) +} + function safeExternalUrl(value: string | undefined): string | undefined { if (!value) return undefined try { @@ -273,6 +286,7 @@ function safeExternalUrl(value: string | undefined): string | undefined { function toDomainPack( packId: string, + fallbackRegistryId: string | undefined, fallbackDisplayName: string, nodes: Array<{ className: string @@ -288,7 +302,7 @@ function toDomainPack( ): Pack { return { id: packId, - registryId: registryPack?.id, + registryId: registryPack?.id ?? fallbackRegistryId, displayName: registryPack?.name?.trim() || fallbackDisplayName || packId, description: registryPack?.description?.trim() || undefined, bannerUrl: safeExternalUrl(registryPack?.banner_url), @@ -338,18 +352,47 @@ async function readSnapshot( snapshotUrl: URL | undefined ): Promise { if (!snapshotUrl) { - return isNodesSnapshot(bundledSnapshot) ? bundledSnapshot : null + return isNodesSnapshot(bundledSnapshot) + ? normalizeSnapshotIds(bundledSnapshot) + : null } try { const text = await readFile(snapshotUrl, 'utf8') const parsed: unknown = JSON.parse(text) - if (isNodesSnapshot(parsed)) return parsed + if (isNodesSnapshot(parsed)) return normalizeSnapshotIds(parsed) return null } catch { return null } } +function normalizeSnapshotIds(snapshot: NodesSnapshot): NodesSnapshot { + const bySlug = new Map() + for (const pack of snapshot.packs) { + const slug = slugifyPackId(pack.id) + if (!slug) continue + const existing = bySlug.get(slug) + if (existing) { + bySlug.set(slug, mergeCollidedPacks(existing, pack)) + continue + } + bySlug.set(slug, { ...pack, id: slug }) + } + return { ...snapshot, packs: [...bySlug.values()] } +} + +function mergeCollidedPacks(first: Pack, next: Pack): Pack { + const merged: Pack = { ...first, nodes: [...first.nodes, ...next.nodes] } + for (const [key, value] of Object.entries(next) as [keyof Pack, unknown][]) { + if (key === 'id' || key === 'nodes') continue + if (value === undefined || value === null) continue + if (merged[key] === undefined || merged[key] === null) { + ;(merged as Record)[key] = value + } + } + return merged +} + function defaultSleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } diff --git a/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts index 6c490b1d1bc..ac977f13a1a 100644 --- a/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts +++ b/packages/object-info-parser/src/__tests__/groupNodesByPack.test.ts @@ -51,4 +51,67 @@ describe('groupNodesByPack', () => { grouped.find((pack) => pack.id === 'comfyui-controlnet-aux')?.nodes ).toHaveLength(1) }) + + it('slugifies pack ids to lowercase, hyphen-only URL slugs', () => { + const grouped = groupNodesByPack({ + A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'), + B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes'), + C: makeNodeDef('C', 'custom_nodes.ComfyUI_yanc.nodes') + }) + + expect(grouped.map((pack) => pack.id)).toEqual([ + 'basic-data-handling', + 'comfyui-crystools', + 'comfyui-yanc' + ]) + }) + + it('preserves the raw upstream id for registry lookups', () => { + const grouped = groupNodesByPack({ + A: makeNodeDef('A', 'custom_nodes.ComfyUI-Crystools.nodes'), + B: makeNodeDef('B', 'custom_nodes.basic_data_handling.nodes') + }) + + expect(grouped.find((pack) => pack.id === 'comfyui-crystools')?.rawId).toBe( + 'ComfyUI-Crystools' + ) + expect( + grouped.find((pack) => pack.id === 'basic-data-handling')?.rawId + ).toBe('basic_data_handling') + }) + + it('merges packs whose raw ids slugify to the same URL slug', () => { + const grouped = groupNodesByPack({ + QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'), + QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI_QwenVL.nodes') + }) + + expect(grouped).toHaveLength(1) + expect(grouped[0].id).toBe('comfyui-qwenvl') + expect(grouped[0].nodes.map((n) => n.className).sort()).toEqual([ + 'QwenA', + 'QwenB' + ]) + expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL', 'ComfyUI_QwenVL']) + }) + + it('does not record duplicate aliases when the same raw id appears twice', () => { + const grouped = groupNodesByPack({ + QwenA: makeNodeDef('QwenA', 'custom_nodes.ComfyUI-QwenVL.nodes'), + QwenB: makeNodeDef('QwenB', 'custom_nodes.ComfyUI-QwenVL.nodes') + }) + + expect(grouped).toHaveLength(1) + expect(grouped[0].rawIds).toEqual(['ComfyUI-QwenVL']) + }) + + it('strips version suffix before slugifying', () => { + const grouped = groupNodesByPack({ + A: makeNodeDef('A', 'custom_nodes.ComfyUI_yanc@1_0_3.nodes') + }) + + expect(grouped).toHaveLength(1) + expect(grouped[0].id).toBe('comfyui-yanc') + expect(grouped[0].rawId).toBe('ComfyUI_yanc') + }) }) diff --git a/packages/object-info-parser/src/__tests__/slugifyPackId.test.ts b/packages/object-info-parser/src/__tests__/slugifyPackId.test.ts new file mode 100644 index 00000000000..6911ad6ce19 --- /dev/null +++ b/packages/object-info-parser/src/__tests__/slugifyPackId.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' + +import { slugifyPackId } from '../helpers/slugifyPackId' + +describe('slugifyPackId', () => { + it.for([ + ['comfyui-impact-pack', 'comfyui-impact-pack'], + ['ComfyUI-Crystools', 'comfyui-crystools'], + ['comfyui_impact_pack', 'comfyui-impact-pack'], + ['ComfyUI_QwenVL', 'comfyui-qwenvl'], + ['basic_data_handling', 'basic-data-handling'], + ['ComfyUI_Step1X-Edit', 'comfyui-step1x-edit'], + ['HunyuanVideo_Foley', 'hunyuanvideo-foley'] + ])('slugifies %s -> %s', ([input, expected]) => { + expect(slugifyPackId(input)).toBe(expected) + }) + + it('collapses runs of hyphens introduced by adjacent separators', () => { + expect(slugifyPackId('a__b')).toBe('a-b') + expect(slugifyPackId('a-_b')).toBe('a-b') + expect(slugifyPackId('a___-_b')).toBe('a-b') + }) + + it('strips leading and trailing separators', () => { + expect(slugifyPackId('_pack_')).toBe('pack') + expect(slugifyPackId('-pack-')).toBe('pack') + expect(slugifyPackId('__a__')).toBe('a') + }) + + it('produces URL-slug-safe output for every registry id observed today', () => { + const samples = [ + 'ComfyUI-AniPortrait', + 'comfyui_aniportrait', + 'ComfyUI-API-Manager', + 'ComfyUI_API_Manager', + 'comfy-oiio', + 'comfy_oiio', + 'ComfyUI-FlashVSR_Ultra_Fast', + 'comfyui-frame-interpolation', + 'Qwen3_TTS', + 'qwen3-tts' + ] + for (const sample of samples) { + expect(slugifyPackId(sample)).toMatch(/^[a-z0-9-]+$/) + } + }) + + it('returns the input unchanged when already a clean slug', () => { + expect(slugifyPackId('comfyui-impact-pack')).toBe('comfyui-impact-pack') + expect(slugifyPackId('rgthree-comfy')).toBe('rgthree-comfy') + }) +}) diff --git a/packages/object-info-parser/src/helpers/groupNodesByPack.ts b/packages/object-info-parser/src/helpers/groupNodesByPack.ts index 509205a800e..b9073060a90 100644 --- a/packages/object-info-parser/src/helpers/groupNodesByPack.ts +++ b/packages/object-info-parser/src/helpers/groupNodesByPack.ts @@ -1,5 +1,6 @@ import { getNodeSource, NodeSourceType } from '../classifiers/nodeSource' import type { ComfyNodeDef } from '../schemas/nodeDefSchema' +import { slugifyPackId } from './slugifyPackId' export interface PackedNode { className: string @@ -8,6 +9,8 @@ export interface PackedNode { export interface NodePack { id: string + rawId: string + rawIds: string[] displayName: string nodes: PackedNode[] } @@ -23,21 +26,31 @@ export function groupNodesByPack( continue } - const packId = def.python_module.split('.')[1]?.split('@')[0] - if (!packId) { + const rawId = def.python_module.split('.')[1]?.split('@')[0] + if (!rawId) { continue } - const existing = byPackId.get(packId) + const slug = slugifyPackId(rawId) + if (!slug) { + continue + } + + const existing = byPackId.get(slug) const node = { className, def } if (existing) { existing.nodes.push(node) + if (!existing.rawIds.includes(rawId)) { + existing.rawIds.push(rawId) + } continue } - byPackId.set(packId, { - id: packId, + byPackId.set(slug, { + id: slug, + rawId, + rawIds: [rawId], displayName: source.displayText, nodes: [node] }) diff --git a/packages/object-info-parser/src/helpers/slugifyPackId.ts b/packages/object-info-parser/src/helpers/slugifyPackId.ts new file mode 100644 index 00000000000..598ce9f616a --- /dev/null +++ b/packages/object-info-parser/src/helpers/slugifyPackId.ts @@ -0,0 +1,34 @@ +/** + * Normalize a custom-node pack identifier into a URL-safe slug. + * + * Pack ids originate from Python module names exposed by ComfyUI and the + * Comfy custom-node registry. The upstream names mix three conventions + * freely: kebab-case (`comfyui-impact-pack`), snake_case + * (`comfyui_impact_pack`), and PascalCase (`ComfyUI-Crystools`). Using + * those raw strings as URL segments produces routes that are inconsistent + * across packs and fail the website's `[a-z0-9-]+` slug contract. + * + * `slugifyPackId` produces a deterministic, lowercase, hyphen-only slug + * suitable for use as a URL segment and as an `Astro.params` value. It + * does NOT replace the raw id used for registry lookups; callers that + * need to query the registry API must keep the raw `node_id` separately. + * + * The transformation is intentionally narrow: + * - lowercase + * - replace `_` with `-` + * - collapse runs of `-` to a single `-` + * - strip leading / trailing `-` + * + * Any other character (digits, letters, `-`) is preserved verbatim so + * legitimate registry ids like `comfyui-flashvsr-ultra-fast` survive + * untouched. The output is guaranteed to match `/^[a-z0-9-]+$/` as long + * as the input only contains ASCII letters, digits, `_`, and `-` — which + * is the case for every pack id observed in the registry today. + */ +export function slugifyPackId(rawId: string): string { + return rawId + .toLowerCase() + .replace(/_/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') +} diff --git a/packages/object-info-parser/src/index.ts b/packages/object-info-parser/src/index.ts index f512634cc92..b8ef87dbb70 100644 --- a/packages/object-info-parser/src/index.ts +++ b/packages/object-info-parser/src/index.ts @@ -2,3 +2,4 @@ export * from './schemas/nodeDefSchema' export * from './classifiers/nodeSource' export * from './helpers/groupNodesByPack' export * from './helpers/sanitizeUserContent' +export * from './helpers/slugifyPackId'