set config via feature flags (#6590)

In cloud environment, allow all the config values to be set by the
feature flag endpoint and be updated dynamically (on 30s poll). Retain
origianl behavior for OSS. On cloud, config changes shouldn't be changed
via redeploy and the promoted image should match the staging image.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6590-set-config-via-feature-flags-2a26d73d3650819f8084eb2695c51f22)
by [Unito](https://www.unito.io)

---------

Co-authored-by: DrJKL <DrJKL0424@gmail.com>
This commit is contained in:
Christian Byrne
2025-11-05 12:45:21 -08:00
committed by GitHub
parent 549ef79e02
commit 437c3b2da0
15 changed files with 200 additions and 43 deletions

14
global.d.ts vendored
View File

@@ -8,7 +8,21 @@ declare const __USE_PROD_CONFIG__: boolean
interface Window {
__CONFIG__: {
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: {
apiKey: string
authDomain: string
databaseURL?: string
projectId: string
storageBucket: string
messagingSenderId: string
appId: string
measurementId?: string
}
server_health_alert?: {
message: string
tooltip?: string

View File

@@ -96,7 +96,7 @@
<small class="text-center text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
:href="`${comfyPlatformBaseUrl}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
>
@@ -145,11 +145,15 @@
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Message from 'primevue/message'
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import type { SignInData, SignUpData } from '@/schemas/signInSchema'
import { isHostWhitelisted, normalizeHost } from '@/utils/hostWhitelist'
import { isInChina } from '@/utils/networkUtil'
@@ -168,6 +172,13 @@ const isSecureContext = window.isSecureContext
const isSignIn = ref(true)
const showApiKeyForm = ref(false)
const ssoAllowed = isHostWhitelisted(normalizeHost(window.location.hostname))
const comfyPlatformBaseUrl = computed(() =>
configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
getComfyPlatformBaseUrl()
)
)
const toggleState = () => {
isSignIn.value = !isSignIn.value

View File

@@ -9,7 +9,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp } from 'vue'
import { createI18n } from 'vue-i18n'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import ApiKeyForm from './ApiKeyForm.vue'
@@ -111,7 +111,7 @@ describe('ApiKeyForm', () => {
const helpText = wrapper.find('small')
expect(helpText.text()).toContain('Need an API key?')
expect(helpText.find('a').attributes('href')).toBe(
`${COMFY_PLATFORM_BASE_URL}/login`
`${getComfyPlatformBaseUrl()}/login`
)
})
})

View File

@@ -48,7 +48,7 @@
<small class="text-muted">
{{ t('auth.apiKey.helpText') }}
<a
:href="`${COMFY_PLATFORM_BASE_URL}/login`"
:href="`${comfyPlatformBaseUrl}/login`"
target="_blank"
class="cursor-pointer text-blue-500"
>
@@ -88,7 +88,11 @@ import Message from 'primevue/message'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_PLATFORM_BASE_URL } from '@/config/comfyApi'
import { getComfyPlatformBaseUrl } from '@/config/comfyApi'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
import { apiKeySchema } from '@/schemas/signInSchema'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -96,6 +100,13 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const apiKeyStore = useApiKeyAuthStore()
const loading = computed(() => authStore.loading)
const comfyPlatformBaseUrl = computed(() =>
configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
getComfyPlatformBaseUrl()
)
)
const { t } = useI18n()

View File

@@ -1,7 +1,43 @@
export const COMFY_API_BASE_URL = __USE_PROD_CONFIG__
? 'https://api.comfy.org'
: 'https://stagingapi.comfy.org'
import { isCloud } from '@/platform/distribution/types'
import {
configValueOrDefault,
remoteConfig
} from '@/platform/remoteConfig/remoteConfig'
export const COMFY_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? 'https://platform.comfy.org'
: 'https://stagingplatform.comfy.org'
const PROD_API_BASE_URL = 'https://api.comfy.org'
const STAGING_API_BASE_URL = 'https://stagingapi.comfy.org'
const PROD_PLATFORM_BASE_URL = 'https://platform.comfy.org'
const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org'
const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__
? PROD_API_BASE_URL
: STAGING_API_BASE_URL
const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
? PROD_PLATFORM_BASE_URL
: STAGING_PLATFORM_BASE_URL
export function getComfyApiBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_API_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_api_base_url',
BUILD_TIME_API_BASE_URL
)
}
export function getComfyPlatformBaseUrl(): string {
if (!isCloud) {
return BUILD_TIME_PLATFORM_BASE_URL
}
return configValueOrDefault(
remoteConfig.value,
'comfy_platform_base_url',
BUILD_TIME_PLATFORM_BASE_URL
)
}

View File

@@ -1,5 +1,8 @@
import type { FirebaseOptions } from 'firebase/app'
import { isCloud } from '@/platform/distribution/types'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
const DEV_CONFIG: FirebaseOptions = {
apiKey: 'AIzaSyDa_YMeyzV0SkVe92vBZ1tVikWBmOU5KVE',
authDomain: 'dreamboothy-dev.firebaseapp.com',
@@ -22,7 +25,18 @@ const PROD_CONFIG: FirebaseOptions = {
measurementId: 'G-3ZBD3MBTG4'
}
// To test with prod config while using dev server, set USE_PROD_CONFIG=true in .env
export const FIREBASE_CONFIG: FirebaseOptions = __USE_PROD_CONFIG__
? PROD_CONFIG
: DEV_CONFIG
const BUILD_TIME_CONFIG = __USE_PROD_CONFIG__ ? PROD_CONFIG : DEV_CONFIG
/**
* 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__
*/
export function getFirebaseConfig(): FirebaseOptions {
if (!isCloud) {
return BUILD_TIME_CONFIG
}
const runtimeConfig = remoteConfig.value.firebase_config
return runtimeConfig ?? BUILD_TIME_CONFIG
}

View File

@@ -11,7 +11,7 @@ import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue'
import { VueFire, VueFireAuth } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase'
import { getFirebaseConfig } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
@@ -40,7 +40,7 @@ const ComfyUIPreset = definePreset(Aura, {
}
})
const firebaseApp = initializeApp(FIREBASE_CONFIG)
const firebaseApp = initializeApp(getFirebaseConfig())
const app = createApp(App)
const pinia = createPinia()

View File

@@ -4,7 +4,7 @@ import { createSharedComposable } from '@vueuse/core'
import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useErrorHandling } from '@/composables/useErrorHandling'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { getComfyApiBaseUrl, getComfyPlatformBaseUrl } from '@/config/comfyApi'
import { MONTHLY_SUBSCRIPTION_PRICE } from '@/config/subscriptionPricesConfig'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
@@ -74,6 +74,8 @@ function useSubscriptionInternal() {
() => `$${MONTHLY_SUBSCRIPTION_PRICE.toFixed(0)}`
)
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
const fetchStatus = wrapWithErrorHandlingAsync(
fetchSubscriptionStatus,
reportError
@@ -114,7 +116,7 @@ function useSubscriptionInternal() {
}
const handleViewUsageHistory = () => {
window.open('https://platform.comfy.org/profile/usage', '_blank')
window.open(`${getComfyPlatformBaseUrl()}/profile/usage`, '_blank')
}
const handleLearnMore = () => {
@@ -136,7 +138,7 @@ function useSubscriptionInternal() {
}
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-status`,
buildApiUrl('/customers/cloud-subscription-status'),
{
headers: {
...authHeader,
@@ -181,7 +183,7 @@ function useSubscriptionInternal() {
}
const response = await fetch(
`${COMFY_API_BASE_URL}/customers/cloud-subscription-checkout`,
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',
headers: {

View File

@@ -21,6 +21,15 @@ import type { RemoteConfig } from './types'
*/
export const remoteConfig = ref<RemoteConfig>({})
export function configValueOrDefault<K extends keyof RemoteConfig>(
remoteConfig: RemoteConfig,
key: K,
defaultValue: NonNullable<RemoteConfig[K]>
): NonNullable<RemoteConfig[K]> {
const configValue = remoteConfig[key]
return configValue || defaultValue
}
/**
* Loads remote configuration from the backend /api/features endpoint
* and updates the reactive remoteConfig ref

View File

@@ -8,6 +8,17 @@ type ServerHealthAlert = {
badge?: string
}
type FirebaseRuntimeConfig = {
apiKey: string
authDomain: string
databaseURL?: string
projectId: string
storageBucket: string
messagingSenderId: string
appId: string
measurementId?: string
}
/**
* Remote configuration type
* Configuration fetched from the server at runtime
@@ -16,4 +27,8 @@ export type RemoteConfig = {
mixpanel_token?: string
subscription_required?: boolean
server_health_alert?: ServerHealthAlert
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: FirebaseRuntimeConfig
}

View File

@@ -1,18 +1,11 @@
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
const releaseApiClient = axios.create({
baseURL: COMFY_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
}
})
// Use generated types from OpenAPI spec
export type ReleaseNote = components['schemas']['ReleaseNote']
type GetReleasesParams = operations['getReleaseNotes']['parameters']['query']
@@ -20,11 +13,25 @@ type GetReleasesParams = operations['getReleaseNotes']['parameters']['query']
// Use generated error response type
type ErrorResponse = components['schemas']['ErrorResponse']
const releaseApiClient = axios.create({
baseURL: getComfyApiBaseUrl(),
headers: {
'Content-Type': 'application/json'
}
})
// Release service for fetching release notes
export const useReleaseService = () => {
const isLoading = ref(false)
const error = ref<string | null>(null)
watch(
() => getComfyApiBaseUrl(),
(url) => {
releaseApiClient.defaults.baseURL = url
}
)
// No transformation needed - API response matches the generated type
// Handle API errors with context

View File

@@ -1,9 +1,9 @@
import type { AxiosError, AxiosResponse } from 'axios'
import axios from 'axios'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
import type { components, operations } from '@/types/comfyRegistryTypes'
import { isAbortError } from '@/utils/typeGuardUtil'
@@ -24,7 +24,7 @@ type CustomerEventsResponseQuery =
export type AuditLog = components['schemas']['AuditLog']
const customerApiClient = axios.create({
baseURL: COMFY_API_BASE_URL,
baseURL: getComfyApiBaseUrl(),
headers: {
'Content-Type': 'application/json'
}
@@ -35,6 +35,13 @@ export const useCustomerEventsService = () => {
const error = ref<string | null>(null)
const { d } = useI18n()
watch(
() => getComfyApiBaseUrl(),
(url) => {
customerApiClient.defaults.baseURL = url
}
)
const handleRequestError = (
err: unknown,
context: string,

View File

@@ -21,7 +21,7 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { useFirebaseAuth } from 'vuefire'
import { COMFY_API_BASE_URL } from '@/config/comfyApi'
import { getComfyApiBaseUrl } from '@/config/comfyApi'
import { t } from '@/i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
@@ -65,6 +65,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Token refresh trigger - increments when token is refreshed
const tokenRefreshTrigger = ref(0)
const buildApiUrl = (path: string) => `${getComfyApiBaseUrl()}${path}`
// Providers
const googleProvider = new GoogleAuthProvider()
googleProvider.addScope('email')
@@ -163,7 +165,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
)
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/balance`, {
const response = await fetch(buildApiUrl('/customers/balance'), {
headers: {
...authHeader,
'Content-Type': 'application/json'
@@ -199,7 +201,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const createCustomerRes = await fetch(`${COMFY_API_BASE_URL}/customers`, {
const createCustomerRes = await fetch(buildApiUrl('/customers'), {
method: 'POST',
headers: {
...authHeader,
@@ -367,7 +369,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
customerCreated.value = true
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/credit`, {
const response = await fetch(buildApiUrl('/customers/credit'), {
method: 'POST',
headers: {
...authHeader,
@@ -401,7 +403,7 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
throw new FirebaseAuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(`${COMFY_API_BASE_URL}/customers/billing`, {
const response = await fetch(buildApiUrl('/customers/billing'), {
method: 'POST',
headers: {
...authHeader,

View File

@@ -283,7 +283,7 @@ describe('useSubscription', () => {
handleViewUsageHistory()
expect(windowOpenSpy).toHaveBeenCalledWith(
'https://platform.comfy.org/profile/usage',
'https://stagingplatform.comfy.org/profile/usage',
'_blank'
)

View File

@@ -6,8 +6,27 @@ declare global {
interface Window {
__CONFIG__: {
mixpanel_token?: string
require_whitelist?: boolean
subscription_required?: boolean
server_health_alert?: string
max_upload_size?: number
comfy_api_base_url?: string
comfy_platform_base_url?: string
firebase_config?: {
apiKey: string
authDomain: string
databaseURL?: string
projectId: string
storageBucket: string
messagingSenderId: string
appId: string
measurementId?: string
}
server_health_alert?: {
message: string
tooltip?: string
severity?: 'info' | 'warning' | 'error'
badge?: string
}
}
}
}
@@ -24,7 +43,17 @@ globalThis.__DISTRIBUTION__ = 'localhost'
// Define runtime config for tests
window.__CONFIG__ = {
subscription_required: true,
mixpanel_token: 'test-token'
mixpanel_token: 'test-token',
comfy_api_base_url: 'https://stagingapi.comfy.org',
comfy_platform_base_url: 'https://stagingplatform.comfy.org',
firebase_config: {
apiKey: 'test',
authDomain: 'test.firebaseapp.com',
projectId: 'test',
storageBucket: 'test.appspot.com',
messagingSenderId: '123',
appId: '123'
}
}
// Mock Worker for extendable-media-recorder