Compare commits

...

7 Commits

Author SHA1 Message Date
Alexander Brown
764977aaf6 Merge branch 'main' into glary/website-cloud-nodes-mock-and-slugify 2026-05-15 07:38:12 -07:00
Glary-Bot
669bd5dbec test(website): cover first-wins alias contract when both registry hits resolve
CodeRabbit nitpick: the existing alias-collision test mocked
ComfyUI-QwenVL -> null + ComfyUI_QwenVL -> hit, so the assertion that
the first hit wins was actually proving 'the only non-null hit wins'.
A regression that returns the last non-null entry would still pass.

New test mocks BOTH aliases to non-null metadata with distinct
registryId/repoUrl, and pins registryId === 'ComfyUI-QwenVL' (the first
alias in pack.rawIds) so a 'last wins' or 'arbitrary wins' regression
fails immediately.
2026-05-14 20:39:54 +00:00
Glary-Bot
c753f30a5f test(website): isolate cloudNodes suite from WEBSITE_CLOUD_NODES_FIXTURE
Defensive hardening per CodeRabbit feedback. WEBSITE_CLOUD_NODES_FIXTURE
is currently read only by loadPacksForBuild (cloudNodes.build.ts), not
by fetchCloudNodesForBuild, so the tests in cloudNodes.test.ts are not
affected today. But clearing the env var in beforeEach and restoring it
in afterEach guards against future refactors that might let the override
bleed into the fetcher, and matches the pattern already used in
cloudNodes.build.test.ts.

Also fixes a latent bug in the existing afterEach: previously,
process.env.WEBSITE_CLOUD_API_KEY = savedCloudApiKey would set the env
var to the literal string 'undefined' when savedCloudApiKey was unset.
Now both env vars are conditionally restored (matching the cloudNodes.build
test convention).
2026-05-14 20:32:40 +00:00
Glary-Bot
1c5a0079f0 fix(website): fall back to raw upstream id when registry enrichment misses
Addresses CodeRabbit review: registryId was sourced purely from
registryPack?.id, so any pack whose registry lookup returned null (or
whose entire fetchRegistryPacks call threw) ended up with registryId
undefined. That breaks the new 'slugged id + raw registryId' contract
and leaves downstream code with no stable upstream identifier.

toDomainPack now takes a fallbackRegistryId derived from pack.rawIds[0]
and uses it when registryPack is null/undefined, so every Pack always
exposes the canonical raw alias even when the registry batch fails.

Two regression tests pin the contract:
* registry-miss path (fetchRegistryPacks returns empty map)
* registry-throw path (fetchRegistryPacks rejects)
2026-05-14 20:26:18 +00:00
Glary-Bot
df3c3c7efc fix(website): preserve optional metadata when snapshot packs merge on slug
Addresses CodeRabbit review: when normalizeSnapshotIds merged two snapshot
packs whose ids slugified to the same value, only the nodes were combined
and every other optional field (registryId, description, repoUrl, publisher,
downloads, githubStars, license, ...) was silently dropped from the later
alias. If the first row lacked metadata the second had, those fields were
lost from the rendered detail page.

mergeCollidedPacks now walks every key on the later pack and fills any
undefined or null fields on the merged result, never overwriting metadata
already present on the first row. The merge stays deterministic
(first-wins for filled fields) and the rule covers the full Pack shape
instead of an ad-hoc hand-listed subset.
2026-05-14 20:18:22 +00:00
Glary-Bot
b58d24403b fix(website): query every raw-id alias when pack slugs collide
Addresses Oracle review feedback: when two raw upstream ids slugify to
the same URL slug (e.g. ComfyUI-QwenVL + ComfyUI_QwenVL both -> comfyui-qwenvl)
the previous merge kept only the first rawId and used only that single
alias to fetch registry metadata. If that one alias missed but its twin
would have resolved, the merged pack lost banner/icon/license info.

Now NodePack carries rawIds: string[] holding every raw alias seen for
the slug. parseCloudNodes flattens all aliases into a single registry
batch and pickRegistryPack walks the alias list in insertion order to
find the first non-null hit.
2026-05-14 20:07:27 +00:00
Glary-Bot
30c106d972 fix(website): slugify cloud-node pack ids and mock e2e snapshot
The website's /cloud/supported-nodes routes use pack ids as URL segments,
but pack ids come straight from upstream Python module names which mix
PascalCase, snake_case, and kebab-case freely. After a Release: Website
snapshot refresh that surfaced packs like ComfyUI-Crystools and
basic_data_handling, the Playwright assertions in cloud-nodes.spec.ts
broke because the slug regex requires kebab-case and the hardcoded
comfyui-impact-pack assertions assumed registry contents would never
change.

Two coordinated fixes:

* slugifyPackId in @comfyorg/object-info-parser normalizes every pack
  id (lowercase + _\u2192-) at the boundaries where it enters the website:
  groupNodesByPack for fresh fetches and readSnapshot for the bundled
  fallback. The raw upstream id is preserved as rawId/registryId so
  registry enrichment continues to hit https://api.comfy.org/nodes
  with the exact node_id the API expects. Packs whose raw ids collide
  on the same slug (24 such pairs exist in the live registry today,
  e.g. ComfyUI-QwenVL + ComfyUI_QwenVL) are merged deterministically
  instead of producing duplicate static routes.

* WEBSITE_CLOUD_NODES_FIXTURE lets the e2e build read a committed
  fixture snapshot in place of the bundled one. CI sets it to
  e2e/fixtures/cloud-nodes.fixture.json so Playwright assertions run
  against deterministic pack content; future snapshot refreshes can
  no longer break the test contract by removing the Impact Pack or
  adding underscored ids.

Verification:
* 99/99 desktop Playwright tests pass against the fixture-built site
* 81/81 website Vitest unit tests pass (including new build/fixture
  coverage)
* 48/48 object-info-parser tests pass (11 new slugifyPackId cases,
  4 new groupNodesByPack cases)
* astro check: 0 errors
2026-05-14 19:54:30 +00:00
12 changed files with 808 additions and 25 deletions

View File

@@ -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

View File

@@ -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."
}
]
}
]
}

View File

@@ -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",

View File

@@ -4,7 +4,9 @@ import type { FetchOutcome } from './cloudNodes'
import type { NodesSnapshot } from '../data/cloudNodes'
const fetchCloudNodesMock = vi.hoisted(() =>
vi.fn<() => Promise<FetchOutcome>>()
vi.fn<
(options?: { snapshotUrl?: URL; apiKey?: string }) => Promise<FetchOutcome>
>()
)
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)
})
})

View File

@@ -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=<path>` 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<Pack[]> {
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<Pack[]> {
)
}
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}`

View File

@@ -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<string, unknown>([
['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<string, unknown>([
[
'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 })
})
})

View File

@@ -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<string, RegistryPack | null>()
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<string, RegistryPack | null>,
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<NodesSnapshot | null> {
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<string, Pack>()
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<keyof Pack, unknown>)[key] = value
}
}
return merged
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}

View File

@@ -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')
})
})

View File

@@ -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')
})
})

View File

@@ -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]
})

View File

@@ -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, '')
}

View File

@@ -2,3 +2,4 @@ export * from './schemas/nodeDefSchema'
export * from './classifiers/nodeSource'
export * from './helpers/groupNodesByPack'
export * from './helpers/sanitizeUserContent'
export * from './helpers/slugifyPackId'