144 lines
4.1 KiB
TypeScript
144 lines
4.1 KiB
TypeScript
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<unknown>;
|
|
};
|
|
|
|
const responseCache = new Map<string, CacheEntry>();
|
|
|
|
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(/\/+$/, "");
|
|
if (!pathname || pathname === "/") {
|
|
parsed.pathname = "/api";
|
|
} else if (pathname.endsWith("/api")) {
|
|
parsed.pathname = pathname;
|
|
} else {
|
|
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<string, string> = {};
|
|
|
|
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<T>(endpoint: string): Promise<T> {
|
|
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} ${cacheKey}): ${details}`);
|
|
}
|
|
|
|
if (!isJson) {
|
|
throw new Error(`gantt-board API request failed (${response.status} ${cacheKey}): expected JSON response`);
|
|
}
|
|
|
|
return payload as T;
|
|
})();
|
|
|
|
responseCache.set(cacheKey, {
|
|
expiresAt: now + revalidateSeconds * 1000,
|
|
value: null,
|
|
promise: requestPromise as Promise<unknown>,
|
|
});
|
|
|
|
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;
|
|
}
|
|
}
|