Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion internal/tunnel/tunnel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
4 changes: 2 additions & 2 deletions web/public-storefront/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
{
Expand Down
24 changes: 9 additions & 15 deletions web/public-storefront/src/components/ServicesList.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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);
Expand All @@ -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 (
Expand Down
24 changes: 24 additions & 0 deletions web/public-storefront/src/lib/catalog-client.ts
Original file line number Diff line number Diff line change
@@ -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<Service[]> {
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);
}
91 changes: 76 additions & 15 deletions web/public-storefront/src/lib/catalog.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
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<string | null> {
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";
Expand All @@ -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<ServiceCatalogDocument>;
return {
displayName: doc.displayName || DEFAULT_STOREFRONT.displayName,
Expand All @@ -47,15 +88,35 @@ function parseCatalogDocument(data: unknown): ServiceCatalogDocument {
};
}

async function fetchCatalogDocumentOnce(
url: string,
): Promise<ServiceCatalogDocument> {
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<ServiceCatalogDocument> => {
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: [] };
},
);

Expand Down
Loading