Compare commits

...

6 Commits

Author SHA1 Message Date
Hunter Senft-Grupp
ef0255e8e0 feat: time-bound /features bootstrap fetch so app.mount can never hang
The frontend awaits refreshRemoteConfig({ useAuth: false }) before mount
so services see the server-configured comfy_api_base_url on first call.
A wedged /features endpoint would otherwise block mount indefinitely.
Cap with a 5s AbortController; on timeout the catch block already clears
remoteConfig and consumers fall back to build-time defaults — same
fallback behavior as a 4xx/5xx response.
2026-05-31 13:51:55 -04:00
Hunter Senft-Grupp
abb3b8921d test: pin backward compat with older comfyui that omits the URL keys 2026-05-31 13:44:50 -04:00
Hunter Senft-Grupp
29d054ee93 refactor: freeze registry client base URL on first use
The base URL is expected to be stable for the lifetime of the
application once read from /api/features. A request-time interceptor
implies it could change between requests, which it cannot. Replace it
with a lazy + memoized client factory (es-toolkit `once`) that reads
the URL exactly once — after main.ts has populated remoteConfig — and
then freezes it.
2026-05-31 13:41:35 -04:00
Hunter Senft-Grupp
2ce68c8a58 refactor: use axios interceptor instead of watcher for runtime base URL
Reading the base URL at axios.create() time captures whatever
remoteConfig holds at module-load — which on OSS is the empty default,
because static imports are hoisted above main.ts's top-level
'await refreshRemoteConfig()'. A subsequent 'watch' never fires because
the value is already correct by the time the watcher is established.
A request-time interceptor reads the current URL on every call, which
is both simpler and actually correct.
2026-05-31 13:38:20 -04:00
Hunter Senft-Grupp
112b3526a9 test: pin fallback to build-time default when override is absent or empty 2026-05-31 13:36:02 -04:00
Hunter Senft-Grupp
0c31464cc5 feat: honor server-provided comfy api/platform base URLs in all distributions
Previously only the cloud distribution consulted window.__CONFIG__ for
comfy_api_base_url / comfy_platform_base_url; OSS builds were locked to
the build-time URL. This made it impossible to point a local comfyui at
an ephemeral api.comfy.org without rebuilding the frontend.

- main.ts: refreshRemoteConfig({ useAuth: false }) now runs in every
  distribution against the local /api/features endpoint
- config/comfyApi.ts: drop the isCloud short-circuit in
  getComfyApiBaseUrl / getComfyPlatformBaseUrl
- services/comfyRegistryService.ts: route through getComfyApiBaseUrl
  instead of hardcoding api.comfy.org; mirrors customerEventsService's
  reactive watch pattern so the registry/manager calls also follow
  runtime overrides

Requires the companion comfyui change that adds comfy_api_base_url and
comfy_platform_base_url to /features.
2026-05-31 13:31:04 -04:00
6 changed files with 210 additions and 52 deletions

View File

@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { refreshRemoteConfig } from '@/platform/remoteConfig/refreshRemoteConfig'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from './comfyApi'
vi.stubGlobal('fetch', vi.fn())
describe('getComfyApiBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = { comfy_api_base_url: 'https://my-ephem.example.com' }
expect(getComfyApiBaseUrl()).toBe('https://my-ephem.example.com')
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_api_base_url: '' }
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
})
})
describe('getComfyPlatformBaseUrl', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors the server-provided override', () => {
remoteConfig.value = {
comfy_platform_base_url: 'https://my-ephem-platform.example.com'
}
expect(getComfyPlatformBaseUrl()).toBe(
'https://my-ephem-platform.example.com'
)
})
it('falls back to the build-time default when the key is absent', () => {
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
it('falls back to the build-time default when the value is empty', () => {
remoteConfig.value = { comfy_platform_base_url: '' }
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})
describe('compatibility with comfyui servers that predate the override keys', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
vi.clearAllMocks()
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('falls back to build-time defaults when /features omits the URL keys', async () => {
// An older comfyui server has /features but doesn't know about
// comfy_api_base_url / comfy_platform_base_url yet.
vi.mocked(global.fetch).mockResolvedValue({
ok: true,
json: async () => ({
supports_preview_metadata: true,
max_upload_size: 104857600
})
} as Response)
await refreshRemoteConfig({ useAuth: false })
expect(getComfyApiBaseUrl()).toBe('https://stagingapi.comfy.org')
expect(getComfyPlatformBaseUrl()).toBe('https://stagingplatform.comfy.org')
})
})

View File

@@ -1,4 +1,3 @@
import { isCloud } from '@/platform/distribution/types'
import {
configValueOrDefault,
remoteConfig
@@ -19,11 +18,16 @@ const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
: (import.meta.env.VITE_STAGING_PLATFORM_BASE_URL ??
STAGING_PLATFORM_BASE_URL)
/**
* Resolves the ComfyUI API base URL.
*
* The local server (any distribution) is authoritative: whatever
* `/api/features` returns for `comfy_api_base_url` wins, falling back to
* the build-time default. This lets a self-hosted comfyui — including
* ephemeral envs — point its frontend at a different api host without
* rebuilding the frontend package.
*/
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
@@ -32,10 +36,6 @@ export function getComfyApiBaseUrl(): string {
}
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',

View File

@@ -32,16 +32,19 @@ import './assets/css/style.css'
import { i18n } from './i18n'
/**
* CRITICAL: Load remote config FIRST for cloud builds to ensure
* window.__CONFIG__is available for all modules during initialization
* CRITICAL: Load remote config FIRST so window.__CONFIG__ is available
* for all modules during initialization. The local /api/features endpoint
* is the source of truth for things like comfy_api_base_url, allowing the
* server (e.g. an ephemeral env) to dictate which backend the frontend
* talks to without a rebuild.
*/
const isCloud = __DISTRIBUTION__ === 'cloud'
if (isCloud) {
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
const { refreshRemoteConfig } =
await import('@/platform/remoteConfig/refreshRemoteConfig')
await refreshRemoteConfig({ useAuth: false })
if (isCloud) {
const { initTelemetry } = await import('@/platform/telemetry/initTelemetry')
await initTelemetry()
}

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '@/scripts/api'
@@ -43,9 +43,10 @@ describe('refreshRemoteConfig', () => {
await refreshRemoteConfig({ useAuth: true })
expect(api.fetchApi).toHaveBeenCalledWith('/features', {
cache: 'no-store'
})
expect(api.fetchApi).toHaveBeenCalledWith(
'/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(global.fetch).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
@@ -67,15 +68,41 @@ describe('refreshRemoteConfig', () => {
await refreshRemoteConfig({ useAuth: false })
expect(global.fetch).toHaveBeenCalledWith('/api/features', {
cache: 'no-store'
})
expect(global.fetch).toHaveBeenCalledWith(
'/api/features',
expect.objectContaining({ cache: 'no-store' })
)
expect(api.fetchApi).not.toHaveBeenCalled()
expect(remoteConfig.value).toEqual(mockConfig)
expect(window.__CONFIG__).toEqual(mockConfig)
})
})
describe('timeout', () => {
afterEach(() => {
vi.useRealTimers()
})
it('aborts and falls back to empty config if /features hangs', async () => {
vi.useFakeTimers()
vi.mocked(global.fetch).mockImplementation(
(_url, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError'))
)
})
)
const inflight = refreshRemoteConfig({ useAuth: false })
await vi.advanceTimersByTimeAsync(5_000)
await inflight
expect(remoteConfig.value).toEqual({})
expect(window.__CONFIG__).toEqual({})
})
})
describe('error handling', () => {
it('clears config on 401 response', async () => {
vi.mocked(api.fetchApi).mockResolvedValue(

View File

@@ -6,6 +6,12 @@ import {
remoteConfigState
} from './remoteConfig'
// Cap the bootstrap fetch so a wedged /features endpoint can never block
// app.mount indefinitely. Same-origin GET against the local comfyui server
// should resolve in well under a second; on timeout the catch block below
// clears remoteConfig and consumers fall back to build-time defaults.
const FEATURES_FETCH_TIMEOUT_MS = 5_000
interface RefreshRemoteConfigOptions {
/**
* Whether to use authenticated API (default: true).
@@ -28,10 +34,22 @@ export async function refreshRemoteConfig(
): Promise<void> {
const { useAuth = true } = options
const controller = new AbortController()
const timeoutId = setTimeout(
() => controller.abort(),
FEATURES_FETCH_TIMEOUT_MS
)
try {
const response = useAuth
? await api.fetchApi('/features', { cache: 'no-store' })
: await fetch('/api/features', { cache: 'no-store' })
? await api.fetchApi('/features', {
cache: 'no-store',
signal: controller.signal
})
: await fetch('/api/features', {
cache: 'no-store',
signal: controller.signal
})
if (response.ok) {
const config = await response.json()
@@ -56,5 +74,7 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
} finally {
clearTimeout(timeoutId)
}
}

View File

@@ -1,22 +1,29 @@
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { once } from 'es-toolkit'
import { ref } from 'vue'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const API_BASE_URL = 'https://api.comfy.org'
const registryApiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
paramsSerializer: {
// Disables PHP-style notation (e.g. param[]=value) in favor of repeated params (e.g. param=value1&param=value2)
indexes: null
}
})
// Lazy + memoized so the base URL is read once — after main.ts's top-level
// `await refreshRemoteConfig()` populates remoteConfig — and then frozen
// for the lifetime of the app. (axios.create here at module load would
// capture the build-time default because static imports are hoisted above
// the await.)
const getRegistryClient = once(() =>
axios.create({
baseURL: getComfyApiBaseUrl(),
headers: {
'Content-Type': 'application/json'
},
paramsSerializer: {
// Disables PHP-style notation (e.g. param[]=value) in favor of repeated params (e.g. param=value1&param=value2)
indexes: null
}
})
)
/**
* Service for interacting with the Comfy Registry API
@@ -118,7 +125,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<
getRegistryClient().get<
operations['ListComfyNodes']['responses'][200]['content']['application/json']
>(endpoint, {
params: queryParams,
@@ -142,7 +149,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<
getRegistryClient().get<
operations['searchNodes']['responses'][200]['content']['application/json']
>(endpoint, { params, signal }),
errorContext
@@ -164,7 +171,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Publisher']>(endpoint, {
getRegistryClient().get<components['schemas']['Publisher']>(endpoint, {
signal
}),
errorContext,
@@ -190,7 +197,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Node'][]>(endpoint, {
getRegistryClient().get<components['schemas']['Node'][]>(endpoint, {
params,
signal
}),
@@ -217,10 +224,14 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.post<components['schemas']['Node']>(endpoint, null, {
params,
signal
}),
getRegistryClient().post<components['schemas']['Node']>(
endpoint,
null,
{
params,
signal
}
),
errorContext,
routeSpecificErrors
)
@@ -238,7 +249,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<
getRegistryClient().get<
operations['listAllNodes']['responses'][200]['content']['application/json']
>(endpoint, { params, signal }),
errorContext
@@ -262,7 +273,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['NodeVersion'][]>(
getRegistryClient().get<components['schemas']['NodeVersion'][]>(
endpoint,
{ params, signal }
),
@@ -288,9 +299,12 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['NodeVersion']>(endpoint, {
signal
}),
getRegistryClient().get<components['schemas']['NodeVersion']>(
endpoint,
{
signal
}
),
errorContext,
routeSpecificErrors
)
@@ -311,7 +325,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Node']>(endpoint, {
getRegistryClient().get<components['schemas']['Node']>(endpoint, {
signal
}),
errorContext,
@@ -352,7 +366,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.get<components['schemas']['Node']>(endpoint, {
getRegistryClient().get<components['schemas']['Node']>(endpoint, {
signal
}),
errorContext,
@@ -399,7 +413,7 @@ export const useComfyRegistryService = () => {
return executeApiRequest(
() =>
registryApiClient.post<
getRegistryClient().post<
components['schemas']['BulkNodeVersionsResponse']
>(endpoint, requestBody, {
signal