const DEFAULT_GANTT_API_BASE_URL = "http://localhost:3000/api"; const DEFAULT_REVALIDATE_SECONDS = 15; let hasWarnedDefaultBaseUrl = false; type CacheEntry = { expiresAt: number; value: unknown; promise?: Promise; }; const responseCache = new Map(); function normalizeBaseUrl(url: string): string { return url.replace(/\/+$/, ""); } function deriveApiBaseUrlFromGanttBoardUrl(): string | null { const ganttBoardUrl = process.env.NEXT_PUBLIC_GANTT_BOARD_URL; if (!ganttBoardUrl) return null; try { const parsed = new URL(ganttBoardUrl); const pathname = parsed.pathname.replace(/\/+$/, ""); parsed.pathname = `${pathname}/api`; parsed.search = ""; parsed.hash = ""; return normalizeBaseUrl(parsed.toString()); } catch { return null; } } export function getGanttApiBaseUrl(): string { const derivedFromGanttBoardUrl = deriveApiBaseUrlFromGanttBoardUrl(); const configured = process.env.GANTT_API_BASE_URL || process.env.NEXT_PUBLIC_GANTT_API_BASE_URL || derivedFromGanttBoardUrl || DEFAULT_GANTT_API_BASE_URL; if ( configured === DEFAULT_GANTT_API_BASE_URL && process.env.NODE_ENV === "production" && !hasWarnedDefaultBaseUrl ) { hasWarnedDefaultBaseUrl = true; console.warn( "[gantt-api] Using default base URL http://localhost:3000/api. Set GANTT_API_BASE_URL or NEXT_PUBLIC_GANTT_API_BASE_URL for deployed environments.", ); } return normalizeBaseUrl(configured); } function getRevalidateSeconds(): number { const raw = Number(process.env.GANTT_API_REVALIDATE_SECONDS || String(DEFAULT_REVALIDATE_SECONDS)); return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_REVALIDATE_SECONDS; } function buildAuthHeaders(): HeadersInit { const headers: Record = {}; if (process.env.GANTT_API_BEARER_TOKEN) { headers.Authorization = `Bearer ${process.env.GANTT_API_BEARER_TOKEN}`; } if (process.env.GANTT_API_COOKIE) { headers.Cookie = process.env.GANTT_API_COOKIE; } return headers; } export async function fetchGanttApi(endpoint: string): Promise { const baseUrl = getGanttApiBaseUrl(); const revalidateSeconds = getRevalidateSeconds(); const cacheKey = `${baseUrl}${endpoint}`; const now = Date.now(); const cached = responseCache.get(cacheKey); if (cached && cached.expiresAt > now) { if (cached.promise) { return (await cached.promise) as T; } return cached.value as T; } const requestPromise = (async () => { const response = await fetch(cacheKey, { method: "GET", cache: "no-store", headers: { Accept: "application/json", ...buildAuthHeaders(), }, }); const contentType = response.headers.get("content-type") || ""; const isJson = contentType.includes("application/json"); const payload = (isJson ? await response.json().catch(() => null) : null) as | { error?: string; message?: string } | null; const nonJsonBody = !isJson ? await response.text().catch(() => "") : ""; if (!response.ok) { const details = payload?.error || payload?.message || (nonJsonBody ? nonJsonBody.replace(/\s+/g, " ").slice(0, 200) : response.statusText); throw new Error(`gantt-board API request failed (${response.status} ${endpoint}): ${details}`); } if (!isJson) { throw new Error(`gantt-board API request failed (${response.status} ${endpoint}): expected JSON response`); } return payload as T; })(); responseCache.set(cacheKey, { expiresAt: now + revalidateSeconds * 1000, value: null, promise: requestPromise as Promise, }); try { const result = await requestPromise; responseCache.set(cacheKey, { expiresAt: Date.now() + revalidateSeconds * 1000, value: result as unknown, }); return result; } catch (error) { responseCache.delete(cacheKey); throw error; } }