Compare commits

...

1 Commits

Author SHA1 Message Date
dante01yoon
feaa05e20e feat: add serverCapabilities module for static REST-based feature fetching
Introduces src/services/serverCapabilities.ts that fetches server
capabilities via GET /api/features before app mount. Replaces the
previous WS-based server→client feature_flags delivery with a simpler
one-shot REST fetch + Object.freeze() pattern.

- initServerCapabilities(): fetch with retry (3 attempts), fallback to empty
- getServerCapability(): dot-notation key access with dev override support
- Called in main.ts before app.mount() so capabilities are available immediately

Fixes #9079
2026-02-22 19:02:14 +09:00
3 changed files with 175 additions and 0 deletions

View File

@@ -14,6 +14,7 @@ import { VueFire, VueFireAuth } from 'vuefire'
import { getFirebaseConfig } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css'
import router from '@/router'
import { initServerCapabilities } from '@/services/serverCapabilities'
import { useBootstrapStore } from '@/stores/bootstrapStore'
import App from './App.vue'
@@ -21,6 +22,8 @@ import App from './App.vue'
import './assets/css/style.css'
import { i18n } from './i18n'
await initServerCapabilities()
/**
* CRITICAL: Load remote config FIRST for cloud builds to ensure
* window.__CONFIG__is available for all modules during initialization

View File

@@ -0,0 +1,131 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
getServerCapability,
initServerCapabilities
} from '@/services/serverCapabilities'
vi.mock('@/platform/distribution/types', () => ({
isCloud: false
}))
describe('serverCapabilities', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
supports_preview_metadata: true,
max_upload_size: 104857600,
node_replacements: false,
extension: { manager: { supports_v4: true } }
})
})
)
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
})
describe('initServerCapabilities', () => {
it('fetches and freezes capabilities on success', async () => {
await initServerCapabilities()
expect(getServerCapability('supports_preview_metadata')).toBe(true)
expect(getServerCapability('max_upload_size')).toBe(104857600)
})
it('retries and falls back to empty object on persistent failure', async () => {
vi.mocked(fetch).mockRejectedValue(new Error('Network error'))
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(3)
expect(getServerCapability('supports_preview_metadata')).toBeUndefined()
expect(warnSpy).toHaveBeenCalledWith(
'Failed to fetch server capabilities after retries'
)
})
it('succeeds on retry after initial failure', async () => {
vi.mocked(fetch)
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ supports_preview_metadata: true })
} as Response)
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(2)
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('falls back to empty object on persistent non-ok response', async () => {
vi.mocked(fetch).mockResolvedValue({
ok: false,
json: () => Promise.resolve({})
} as Response)
await initServerCapabilities()
expect(fetch).toHaveBeenCalledTimes(3)
expect(getServerCapability('supports_preview_metadata')).toBeUndefined()
})
})
describe('getServerCapability', () => {
it('returns default value when called before init', () => {
expect(getServerCapability('some_key', 'fallback')).toBe('fallback')
})
beforeEach(async () => {
await initServerCapabilities()
})
it('returns value for existing key', () => {
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('returns default value for missing key', () => {
expect(getServerCapability('non_existent', 'fallback')).toBe('fallback')
})
it('supports dot notation for nested values', () => {
expect(getServerCapability('extension.manager.supports_v4')).toBe(true)
})
it('returns undefined for missing key with no default', () => {
expect(getServerCapability('missing_key')).toBeUndefined()
})
})
describe('dev override via localStorage', () => {
beforeEach(async () => {
await initServerCapabilities()
})
afterEach(() => {
localStorage.clear()
})
it('returns localStorage override over server value', () => {
localStorage.setItem('ff:supports_preview_metadata', 'false')
expect(getServerCapability('supports_preview_metadata')).toBe(false)
})
it('falls through to server value when no override is set', () => {
expect(getServerCapability('supports_preview_metadata')).toBe(true)
})
it('override works with numeric values', () => {
localStorage.setItem('ff:max_upload_size', '999')
expect(getServerCapability('max_upload_size')).toBe(999)
})
})
})

View File

@@ -0,0 +1,41 @@
import { get } from 'es-toolkit/compat'
import { isCloud } from '@/platform/distribution/types'
import { getDevOverride } from '@/utils/devFeatureFlagOverride'
const EMPTY: Readonly<Record<string, unknown>> = Object.freeze({})
const MAX_RETRIES = 2
let capabilities: Readonly<Record<string, unknown>> = EMPTY
function getApiBase(): string {
return isCloud ? '' : location.pathname.split('/').slice(0, -1).join('/')
}
export async function initServerCapabilities(): Promise<void> {
const url = `${getApiBase()}/api/features`
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
const res = await fetch(url, { cache: 'no-store' })
if (res.ok) {
capabilities = Object.freeze(await res.json())
return
}
} catch {
// Retry on network errors
}
}
console.warn('Failed to fetch server capabilities after retries')
capabilities = EMPTY
}
export function getServerCapability<T = unknown>(
key: string,
defaultValue?: T
): T {
const override = getDevOverride<T>(key)
if (override !== undefined) return override
return get(capabilities, key, defaultValue) as T
}