Compare commits

..

1 Commits

Author SHA1 Message Date
bigcat88
cf743efced feat: follow --comfy-api-base for staging and preview backends
Read the api and platform base from /features (remoteConfig) for all builds, and pick the dev Firebase project when the api base is staging-tier, so a prod bundle can talk to a staging or preview backend without a rebuild.
2026-06-20 23:01:24 +03:00
7 changed files with 230 additions and 34 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,14 @@ 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.
* That way the server can 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',
@@ -31,11 +33,11 @@ export function getComfyApiBaseUrl(): string {
)
}
/**
* Resolves the ComfyUI Platform base URL.
* As with the api base, the server's `/api/features` (`comfy_platform_base_url`) overrides the build-time default.
*/
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',

View File

@@ -0,0 +1,46 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
import { getFirebaseConfig } from './firebase'
describe('getFirebaseConfig', () => {
const originalConfig = remoteConfig.value
beforeEach(() => {
remoteConfig.value = {}
})
afterEach(() => {
remoteConfig.value = originalConfig
})
it('honors a full server-provided firebase_config (cloud builds)', () => {
const cloud = {
apiKey: 'cloud-key',
authDomain: 'cloud.example.com',
projectId: 'some-cloud-project',
storageBucket: 'cloud.appspot.com',
messagingSenderId: '1',
appId: '1:1:web:abc'
}
remoteConfig.value = { firebase_config: cloud }
expect(getFirebaseConfig()).toEqual(cloud)
})
it('uses the dev project for a staging-tier api base (staging or testenv)', () => {
// No firebase_config from the server — the dev project is derived from the
// api base, using the DEV config bundled in the frontend.
remoteConfig.value = { comfy_api_base_url: 'https://stagingapi.comfy.org' }
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
remoteConfig.value = {
comfy_api_base_url: 'https://pr-1-registry.testenvs.comfy.org'
}
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
it('falls back to the build-time config otherwise', () => {
// The test build uses the non-prod config => dreamboothy-dev.
expect(getFirebaseConfig().projectId).toBe('dreamboothy-dev')
})
})

View File

@@ -1,6 +1,5 @@
import type { FirebaseOptions } from 'firebase/app'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEV_CONFIG: FirebaseOptions = {
@@ -27,16 +26,28 @@ const PROD_CONFIG: FirebaseOptions = {
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
const STAGING_API_HOST = 'stagingapi.comfy.org'
const TESTENV_HOST_SUFFIX = '.testenvs.comfy.org'
// staging + the ephemeral testenvs use the dev Firebase project (prod uses prod)
function isStagingTierApiBase(apiBase: string | undefined): boolean {
if (!apiBase) return false
try {
const host = new URL(apiBase).hostname
return host === STAGING_API_HOST || host.endsWith(TESTENV_HOST_SUFFIX)
} catch {
return false
}
}
/**
* Returns the Firebase configuration for the current environment.
* - Cloud builds use runtime configuration delivered via feature flags
* - OSS / localhost builds fall back to the build-time config determined by __USE_PROD_CONFIG__
* Firebase config for the current backend: the server's firebase_config (cloud builds),
* else the bundled DEV_CONFIG when the api base is staging-tier, else the build-time default.
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
if (runtimeConfig) return runtimeConfig
if (isStagingTierApiBase(remoteConfig.value.comfy_api_base_url))
return DEV_CONFIG
return BUILD_TIME_CONFIG
}

View File

@@ -30,15 +30,18 @@ import App from './App.vue'
import './assets/css/style.css'
import { i18n } from './i18n'
/**
* 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 runtime config (api base, Firebase project, …).
* Allows the server to dictate which backend the frontend talks to and which Firebase project it logs in against.
* Must run before initializeApp() below so getFirebaseConfig() sees it.
*/
const isCloud = __DISTRIBUTION__ === 'cloud'
const hasHostTelemetryBridge = Boolean(window.__comfyDesktop2?.Telemetry)
const requiresRemoteConfigBootstrap = isCloud || hasHostTelemetryBridge
if (requiresRemoteConfigBootstrap) {
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')

View File

@@ -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,38 @@ 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', () => {
it('passes an AbortSignal so a wedged /features cannot hang startup', async () => {
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
await refreshRemoteConfig({ useAuth: false })
const init = vi.mocked(global.fetch).mock.calls[0][1]
expect(init?.signal).toBeInstanceOf(AbortSignal)
})
it('falls back to empty config when the request aborts', async () => {
vi.mocked(global.fetch).mockRejectedValue(
new DOMException('Aborted', 'AbortError')
)
await refreshRemoteConfig({ useAuth: false })
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

@@ -4,6 +4,11 @@ import {
remoteConfigState
} from './remoteConfig'
// Cap the bootstrap fetch so a wedged /features endpoint can never block app.mount indefinitely.
// A same-origin GET against the local comfyui server should resolve in well under a second;
// on timeout the catch 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).
@@ -12,11 +17,14 @@ interface RefreshRemoteConfigOptions {
useAuth?: boolean
}
async function fetchRemoteConfig(useAuth: boolean): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store' })
async function fetchRemoteConfig(
useAuth: boolean,
signal: AbortSignal
): Promise<Response> {
if (!useAuth) return fetch('/api/features', { cache: 'no-store', signal })
const { api } = await import('@/scripts/api')
return api.fetchApi('/features', { cache: 'no-store' })
return api.fetchApi('/features', { cache: 'no-store', signal })
}
/**
@@ -33,8 +41,14 @@ 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 = await fetchRemoteConfig(useAuth)
const response = await fetchRemoteConfig(useAuth, controller.signal)
if (response.ok) {
const config = await response.json()
@@ -59,5 +73,7 @@ export async function refreshRemoteConfig(
window.__CONFIG__ = {}
remoteConfig.value = {}
remoteConfigState.value = 'error'
} finally {
clearTimeout(timeoutId)
}
}