From dd9e8da61960a978be2db27e2655d3cf2f08e6d7 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 20:12:35 +0000 Subject: [PATCH] refactor(gui): centralize API origin handling --- ui/src/app/dashboard.component.ts | 2 +- ui/src/components/provider-host.component.ts | 2 +- ui/src/services/api-config.service.ts | 7 +- ui/src/services/provider-discovery.service.ts | 67 +++++++++++++------ ui/src/services/websocket.service.ts | 6 +- 5 files changed, 58 insertions(+), 26 deletions(-) diff --git a/ui/src/app/dashboard.component.ts b/ui/src/app/dashboard.component.ts index 43d60f8..f514fdd 100644 --- a/ui/src/app/dashboard.component.ts +++ b/ui/src/app/dashboard.component.ts @@ -165,7 +165,7 @@ export class DashboardComponent { protected readonly providers = this.discovery.providers; protected readonly providerCount = computed(() => this.providers().length); protected readonly connected = this.websocket.connected; - protected readonly apiBase = computed(() => this.apiConfig.baseUrl || window.location.origin); + protected readonly apiBase = computed(() => this.apiConfig.effectiveBaseUrl); protected readonly featuredProviders = computed(() => this.providers().filter((provider) => provider.element?.tag).slice(0, 6), diff --git a/ui/src/components/provider-host.component.ts b/ui/src/components/provider-host.component.ts index 6d75af2..1b64c8e 100644 --- a/ui/src/components/provider-host.component.ts +++ b/ui/src/components/provider-host.component.ts @@ -110,7 +110,7 @@ export class ProviderHostComponent implements OnInit, OnChanges { // Create and append the custom element const el = this.renderer.createElement(this.tag); - const url = this.apiUrl || this.apiConfig.baseUrl; + const url = this.apiUrl || this.apiConfig.effectiveBaseUrl; if (url) { this.renderer.setAttribute(el, 'api-url', url); } diff --git a/ui/src/services/api-config.service.ts b/ui/src/services/api-config.service.ts index dd41b2a..ca1a43f 100644 --- a/ui/src/services/api-config.service.ts +++ b/ui/src/services/api-config.service.ts @@ -16,6 +16,11 @@ export class ApiConfigService { return this._baseUrl; } + /** The effective API base URL, falling back to the current origin. */ + get effectiveBaseUrl(): string { + return this._baseUrl || window.location.origin; + } + /** Override the base URL. Strips trailing slash if present. */ set baseUrl(url: string) { this._baseUrl = url.replace(/\/+$/, ''); @@ -24,6 +29,6 @@ export class ApiConfigService { /** Build a full URL for the given path. */ url(path: string): string { const cleanPath = path.startsWith('/') ? path : `/${path}`; - return `${this._baseUrl}${cleanPath}`; + return `${this.effectiveBaseUrl}${cleanPath}`; } } diff --git a/ui/src/services/provider-discovery.service.ts b/ui/src/services/provider-discovery.service.ts index 2cc8410..880f031 100644 --- a/ui/src/services/provider-discovery.service.ts +++ b/ui/src/services/provider-discovery.service.ts @@ -33,42 +33,69 @@ export class ProviderDiscoveryService { readonly providers = this._providers.asReadonly(); private discovered = false; + private discoveryPromise: Promise | null = null; + private discoveryGeneration = 0; constructor(private apiConfig: ApiConfigService) {} /** Fetch providers from the API and load custom element scripts. */ - async discover(): Promise { - if (this.discovered) { + async discover(force = false): Promise { + if (!force && this.discovered) { return; } + if (!force && this.discoveryPromise) { + return this.discoveryPromise; + } + + const generation = ++this.discoveryGeneration; + const promise = this.doDiscover(generation); + this.discoveryPromise = promise; + try { - const res = await fetch(this.apiConfig.url('/api/v1/providers')); - if (!res.ok) { - console.warn('ProviderDiscoveryService: failed to fetch providers:', res.statusText); - return; - } - - const data = await res.json(); - const providers: ProviderInfo[] = data.providers ?? []; - this._providers.set(providers); - this.discovered = true; - - // Load custom elements for Renderable providers - for (const p of providers) { - if (p.element?.tag && p.element?.source) { - await this.loadElement(p.element.tag, p.element.source); - } - } + await promise; } catch (err) { console.warn('ProviderDiscoveryService: discovery failed:', err); + } finally { + if (this.discoveryPromise === promise) { + this.discoveryPromise = null; + } } } /** Refresh the provider list (force re-discovery). */ async refresh(): Promise { this.discovered = false; - await this.discover(); + await this.discover(true); + } + + private async doDiscover(generation: number): Promise { + const res = await fetch(this.apiConfig.url('/api/v1/providers')); + if (!res.ok) { + console.warn('ProviderDiscoveryService: failed to fetch providers:', res.statusText); + return; + } + + const data = await res.json(); + const providers: ProviderInfo[] = data.providers ?? []; + + if (generation !== this.discoveryGeneration) { + return; + } + + this._providers.set(providers); + this.discovered = true; + + // Load custom elements for renderable providers. + for (const provider of providers) { + if (generation !== this.discoveryGeneration) { + return; + } + + if (provider.element?.tag && provider.element?.source) { + await this.loadElement(provider.element.tag, provider.element.source); + } + } } /** Dynamically load a custom element script if not already registered. */ diff --git a/ui/src/services/websocket.service.ts b/ui/src/services/websocket.service.ts index 7c1f179..feb7f47 100644 --- a/ui/src/services/websocket.service.ts +++ b/ui/src/services/websocket.service.ts @@ -32,10 +32,10 @@ export class WebSocketService implements OnDestroy { } this.shouldReconnect = true; - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const base = this.apiConfig.baseUrl || window.location.origin; + const cleanPath = path.startsWith('/') ? path : `/${path}`; + const base = this.apiConfig.effectiveBaseUrl; const wsBase = base.replace(/^http/, 'ws'); - const url = `${wsBase.length > 0 ? wsBase : `${protocol}//${window.location.host}`}${path}`; + const url = `${wsBase}${cleanPath}`; this.ws = new WebSocket(url);