mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-11 01:28:03 +00:00
## Summary Let the running ComfyUI server decide which backend the web UI talks to (and which Firebase project it signs you into), so launching with `--comfy-api-base` just works with the regular bundled frontend. ## Changes - **What**: At startup the frontend reads `/api/features` on every build (not just cloud) and treats the server's `comfy_api_base_url` / `comfy_platform_base_url` as authoritative, falling back to the build-time defaults. When that api base is a staging-tier host (staging, or a `*.testenvs.comfy.org` preview env) and the server hasn't supplied its own Firebase config, the frontend picks the dev Firebase project, derived from the api base. Production is left exactly as it is today. - `main.ts`: load remote config first thing, before Firebase initializes, so every module sees the right values from the first render - `config/comfyApi.ts`: the api/platform getters now read the server's values on all distributions - `config/firebase.ts`: `getFirebaseConfig()` resolves in order: a server-provided config first (cloud), then the dev project for a staging-tier api base, then the build-time default - `platform/remoteConfig/refreshRemoteConfig.ts`: the startup fetch now has a 5s timeout, so a slow or wedged `/features` can never keep the app from mounting; on failure we fall back to the build-time defaults - **Breaking**: None. With no `/features` overrides (production and ordinary self-hosting), behavior is unchanged ## Review Focus - The precedence in `getFirebaseConfig()` (`config/firebase.ts`): server config first, then the staging-tier dev project, then the build-time default. The staging-tier check matches `stagingapi.comfy.org` and any `*.testenvs.comfy.org` host, and falls back to build-time for anything it can't parse. - Running `refreshRemoteConfig()` unconditionally and first in `main.ts`, with the new fetch timeout as the safety net. ## Testing I tested every case by hand, locally, on top of the automated checks. Tested both with `pnpm run build` and `USE_PROD_CONFIG=true pnpm build` and running Comfy from that folder. Pointed a local ComfyUI at each backend with `--comfy-api-base` and signed in with Google each time: - **Production** (default / `https://api.comfy.org`): stays on production and signs into the production Firebase project, identical to today. - **Staging** (`https://stagingapi.comfy.org`): follows it and signs into the dev project. - **Ephemeral preview env** (`https://pr-<n>.testenvs.comfy.org`): the friendly host is accepted as-is, the frontend follows it, lands in the dev project, and Google sign-in completes. The only exception where fronted does not respect the `--comfy-api-base` is when Comfy runs against `prod` and frontend runs with the `pnpm run dev` - due to overridden config(this is expected behavior). Supersedes: https://github.com/Comfy-Org/ComfyUI_frontend/pull/12560 Companion Core PR: https://github.com/Comfy-Org/ComfyUI/pull/14569 ## Screenshots (if applicable) <!-- Add screenshots or video recording to help explain your changes -->
162 lines
4.6 KiB
TypeScript
162 lines
4.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
import { api } from '@/scripts/api'
|
|
|
|
import { refreshRemoteConfig } from './refreshRemoteConfig'
|
|
import { remoteConfig } from './remoteConfig'
|
|
|
|
vi.mock('@/scripts/api', () => ({
|
|
api: {
|
|
fetchApi: vi.fn(),
|
|
apiURL: vi.fn((route: string) => `/ComfyUI/api${route}`)
|
|
}
|
|
}))
|
|
|
|
vi.stubGlobal('fetch', vi.fn())
|
|
|
|
describe('refreshRemoteConfig', () => {
|
|
const mockConfig = { feature1: true, feature2: 'value' }
|
|
|
|
function mockSuccessResponse(config = mockConfig) {
|
|
return {
|
|
ok: true,
|
|
json: async () => config
|
|
} as Response
|
|
}
|
|
|
|
function mockErrorResponse(status: number, statusText: string) {
|
|
return {
|
|
ok: false,
|
|
status,
|
|
statusText
|
|
} as Response
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
remoteConfig.value = {}
|
|
window.__CONFIG__ = {}
|
|
})
|
|
|
|
describe('with auth (default)', () => {
|
|
it('uses api.fetchApi when useAuth is true', async () => {
|
|
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
|
|
|
|
await refreshRemoteConfig({ useAuth: true })
|
|
|
|
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)
|
|
})
|
|
|
|
it('uses api.fetchApi by default', async () => {
|
|
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
|
|
|
|
await refreshRemoteConfig()
|
|
|
|
expect(api.fetchApi).toHaveBeenCalled()
|
|
expect(global.fetch).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not pass an abort signal on the authed branch (so it is never aborted)', async () => {
|
|
vi.mocked(api.fetchApi).mockResolvedValue(mockSuccessResponse())
|
|
|
|
await refreshRemoteConfig({ useAuth: true })
|
|
|
|
const init = vi.mocked(api.fetchApi).mock.calls[0][1]
|
|
expect(init?.signal).toBeUndefined()
|
|
})
|
|
})
|
|
|
|
describe('without auth', () => {
|
|
it('builds the no-auth url via api.apiURL so a path prefix is respected', async () => {
|
|
vi.mocked(global.fetch).mockResolvedValue(mockSuccessResponse())
|
|
|
|
await refreshRemoteConfig({ useAuth: false })
|
|
|
|
expect(api.apiURL).toHaveBeenCalledWith('/features')
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'/ComfyUI/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(
|
|
mockErrorResponse(401, 'Unauthorized')
|
|
)
|
|
|
|
await refreshRemoteConfig()
|
|
|
|
expect(remoteConfig.value).toEqual({})
|
|
expect(window.__CONFIG__).toEqual({})
|
|
})
|
|
|
|
it('clears config on 403 response', async () => {
|
|
vi.mocked(api.fetchApi).mockResolvedValue(
|
|
mockErrorResponse(403, 'Forbidden')
|
|
)
|
|
|
|
await refreshRemoteConfig()
|
|
|
|
expect(remoteConfig.value).toEqual({})
|
|
expect(window.__CONFIG__).toEqual({})
|
|
})
|
|
|
|
it('clears config on fetch error', async () => {
|
|
vi.mocked(api.fetchApi).mockRejectedValue(new Error('Network error'))
|
|
|
|
await refreshRemoteConfig()
|
|
|
|
expect(remoteConfig.value).toEqual({})
|
|
expect(window.__CONFIG__).toEqual({})
|
|
})
|
|
|
|
it('preserves config on 500 response', async () => {
|
|
const existingConfig = { subscription_required: true }
|
|
remoteConfig.value = existingConfig
|
|
window.__CONFIG__ = existingConfig
|
|
|
|
vi.mocked(api.fetchApi).mockResolvedValue(
|
|
mockErrorResponse(500, 'Internal Server Error')
|
|
)
|
|
|
|
await refreshRemoteConfig()
|
|
|
|
expect(remoteConfig.value).toEqual(existingConfig)
|
|
expect(window.__CONFIG__).toEqual(existingConfig)
|
|
})
|
|
})
|
|
})
|