diff --git a/internal/tunnel/tunnel.go b/internal/tunnel/tunnel.go index 42513674..3d2e057e 100644 --- a/internal/tunnel/tunnel.go +++ b/internal/tunnel/tunnel.go @@ -1271,7 +1271,9 @@ func CreateStorefront(cfg *config.Config, hostnames ...string) error { {"containerPort": 3000, "name": "http"}, }, "env": []map[string]string{ - {"name": "SERVICES_URL", "value": "http://obol-skill-md.x402.svc:8080"}, + {"name": "SERVICES_URL", "value": "http://obol-skill-md.x402.svc.cluster.local:8080"}, + // Bind on all interfaces so in-pod probes and SSR self-fetches work. + {"name": "HOSTNAME", "value": "0.0.0.0"}, }, // Next.js SSR `/` cold renders can take >1s (the // implicit livenessProbe timeoutSeconds default). diff --git a/web/public-storefront/next.config.ts b/web/public-storefront/next.config.ts index 42291dc4..52b1f7a7 100644 --- a/web/public-storefront/next.config.ts +++ b/web/public-storefront/next.config.ts @@ -5,8 +5,8 @@ const servicesURL = const nextConfig: NextConfig = { output: "standalone", - // Local dev has no Traefik in front — proxy catalog JSON through Next so the - // client-side refresh in ServicesList hits the cluster via SERVICES_URL. + // Local dev has no Traefik in front — proxy catalog JSON through Next so SSR + // and client refresh in ServicesList hit the cluster via SERVICES_URL. async rewrites() { return [ { diff --git a/web/public-storefront/src/components/ServicesList.tsx b/web/public-storefront/src/components/ServicesList.tsx index 889f8801..37746904 100644 --- a/web/public-storefront/src/components/ServicesList.tsx +++ b/web/public-storefront/src/components/ServicesList.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { fetchPublicCatalog } from "@/lib/catalog-client"; import type { Service } from "@/types"; import { ServiceCard } from "./ServiceCard"; @@ -11,20 +12,18 @@ export function ServicesList({ initial }: { initial: Service[] }) { useEffect(() => { let cancelled = false; - const tick = async () => { + + async function syncCatalog() { try { - const res = await fetch("/api/services.json", { cache: "no-store" }); - if (!res.ok) return; - const data = await res.json(); - const fresh: Service[] = Array.isArray(data?.services) - ? data.services - : []; + const fresh = await fetchPublicCatalog(); if (!cancelled) setServices(fresh); } catch { - // Network blip — keep existing list, retry next tick. + // Network blip — keep SSR snapshot, retry on next interval. } - }; - const id = setInterval(tick, REFRESH_INTERVAL_MS); + } + + void syncCatalog(); + const id = setInterval(syncCatalog, REFRESH_INTERVAL_MS); return () => { cancelled = true; clearInterval(id); @@ -48,11 +47,6 @@ export function ServicesList({ initial }: { initial: Service[] }) { ); } - // Group into storefront sections by category. Demo is just another - // category — no special-casing. Services arrive pre-sorted by the catalog - // (weight desc, then name), so iterating in order and emitting categories - // as first encountered makes the section order follow weight too - // (uncategorized services render under "Services"). const sections = groupByCategory(services); return ( diff --git a/web/public-storefront/src/lib/catalog-client.ts b/web/public-storefront/src/lib/catalog-client.ts new file mode 100644 index 00000000..883c4e39 --- /dev/null +++ b/web/public-storefront/src/lib/catalog-client.ts @@ -0,0 +1,24 @@ +import type { Service } from "@/types"; + +function unwrapServices(data: unknown): Service[] { + if (Array.isArray(data)) { + return data as Service[]; + } + if (data && typeof data === "object") { + const services = (data as { services?: unknown }).services; + if (Array.isArray(services)) { + return services as Service[]; + } + } + return []; +} + +/** Browser-side catalog fetch (Traefik / Next rewrite → obol-skill-md). */ +export async function fetchPublicCatalog(): Promise { + const res = await fetch("/api/services.json", { cache: "no-store" }); + if (!res.ok) { + throw new Error(`catalog ${res.status} ${res.statusText}`); + } + const data: unknown = await res.json(); + return unwrapServices(data); +} diff --git a/web/public-storefront/src/lib/catalog.ts b/web/public-storefront/src/lib/catalog.ts index e1ad5eee..8f00a719 100644 --- a/web/public-storefront/src/lib/catalog.ts +++ b/web/public-storefront/src/lib/catalog.ts @@ -1,16 +1,41 @@ +import { unstable_noStore as noStore } from "next/cache"; +import { headers } from "next/headers"; import { cache } from "react"; import type { Service, StorefrontProfile } from "@/types"; -const SERVICES_URL = - process.env.SERVICES_URL ?? "http://obol-skill-md.x402.svc:8080"; +const DEFAULT_UPSTREAM = + process.env.SERVICES_URL ?? "http://obol-skill-md.x402.svc.cluster.local:8080"; -const CATALOG_FETCH_TIMEOUT_MS = 5_000; +const CATALOG_FETCH_TIMEOUT_MS = 8_000; -async function fetchCatalog(path: string): Promise { - return fetch(`${SERVICES_URL}${path}`, { - cache: "no-store", - signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS), - }); +function upstreamBase(): string { + return DEFAULT_UPSTREAM.replace(/\/$/, ""); +} + +function isPodAddress(host: string): boolean { + const hostname = host.split(":")[0] ?? ""; + return /^\d+\.\d+\.\d+\.\d+$/.test(hostname); +} + +// SSR should use the same /api/services.json URL the browser hits (Traefik → +// obol-skill-md). Probes and cold starts often arrive with a pod IP Host +// header — fall back to the in-cluster upstream in that case. +async function resolvePublicCatalogURL(): Promise { + try { + const h = await headers(); + const host = h.get("x-forwarded-host") ?? h.get("host"); + if (!host || isPodAddress(host)) { + return null; + } + const proto = + h.get("x-forwarded-proto") ?? + (host.includes("localhost") || host.startsWith("obol.stack") + ? "http" + : "https"); + return `${proto}://${host}/api/services.json`; + } catch { + return null; + } } export const DEFAULT_LOGO_PATH = "/obol-stack-logo.png"; @@ -34,10 +59,26 @@ export interface ServiceCatalogDocument extends StorefrontProfile { services: Service[]; } +function unwrapServices(data: unknown): Service[] { + if (Array.isArray(data)) { + return data as Service[]; + } + if (data && typeof data === "object") { + const services = (data as { services?: unknown }).services; + if (Array.isArray(services)) { + return services as Service[]; + } + } + return []; +} + function parseCatalogDocument(data: unknown): ServiceCatalogDocument { - if (!data || typeof data !== "object" || Array.isArray(data)) { + if (!data || typeof data !== "object") { return { ...DEFAULT_STOREFRONT, services: [] }; } + if (Array.isArray(data)) { + return { ...DEFAULT_STOREFRONT, services: data as Service[] }; + } const doc = data as Partial; return { displayName: doc.displayName || DEFAULT_STOREFRONT.displayName, @@ -47,15 +88,35 @@ function parseCatalogDocument(data: unknown): ServiceCatalogDocument { }; } +async function fetchCatalogDocumentOnce( + url: string, +): Promise { + const res = await fetch(url, { + cache: "no-store", + signal: AbortSignal.timeout(CATALOG_FETCH_TIMEOUT_MS), + }); + if (!res.ok) { + throw new Error(`catalog ${res.status} ${res.statusText} from ${url}`); + } + return parseCatalogDocument(await res.json()); +} + export const fetchCatalogDocument = cache( async (): Promise => { - try { - const res = await fetchCatalog("/api/services.json"); - if (!res.ok) return { ...DEFAULT_STOREFRONT, services: [] }; - return parseCatalogDocument(await res.json()); - } catch { - return { ...DEFAULT_STOREFRONT, services: [] }; + noStore(); + const upstream = `${upstreamBase()}/api/services.json`; + const publicURL = await resolvePublicCatalogURL(); + const urls = + publicURL && publicURL !== upstream ? [publicURL, upstream] : [upstream]; + + for (const url of urls) { + try { + return await fetchCatalogDocumentOnce(url); + } catch (err) { + console.error("[storefront] catalog fetch failed:", url, err); + } } + return { ...DEFAULT_STOREFRONT, services: [] }; }, );