mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-08 15:29:52 +00:00
Compare commits
6 Commits
jaeone/fe-
...
comfy-api-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef0255e8e0 | ||
|
|
abb3b8921d | ||
|
|
29d054ee93 | ||
|
|
2ce68c8a58 | ||
|
|
112b3526a9 | ||
|
|
0c31464cc5 |
94
src/config/comfyApi.test.ts
Normal file
94
src/config/comfyApi.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
15
src/main.ts
15
src/main.ts
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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¶m=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¶m=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
|
||||
|
||||
Reference in New Issue
Block a user