144 lines
3.4 KiB
TypeScript
144 lines
3.4 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
|
|
type CacheStatus = "HIT" | "MISS" | "STALE" | "FALLBACK";
|
|
|
|
type PublicCacheEntry<T> = {
|
|
data: T;
|
|
expiresAt: number;
|
|
staleUntil: number;
|
|
};
|
|
|
|
const PUBLIC_API_CACHE_KEY = Symbol.for("ai-chatbot.public-api-cache");
|
|
|
|
function getStore<T>() {
|
|
const globalCache = globalThis as typeof globalThis & {
|
|
[PUBLIC_API_CACHE_KEY]?: Map<string, PublicCacheEntry<T>>;
|
|
};
|
|
|
|
if (!globalCache[PUBLIC_API_CACHE_KEY]) {
|
|
globalCache[PUBLIC_API_CACHE_KEY] = new Map<string, PublicCacheEntry<T>>();
|
|
}
|
|
|
|
return globalCache[PUBLIC_API_CACHE_KEY]!;
|
|
}
|
|
|
|
export function getEnvInt(
|
|
name: string,
|
|
fallback: number,
|
|
min: number,
|
|
max: number
|
|
) {
|
|
const value = Number.parseInt(process.env[name] || "", 10);
|
|
if (!Number.isFinite(value)) {
|
|
return fallback;
|
|
}
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
export function withTimeout<T>(
|
|
promise: Promise<T>,
|
|
timeoutMs: number,
|
|
label = "operation"
|
|
) {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const timeout = new Promise<never>((_, reject) => {
|
|
timer = setTimeout(() => {
|
|
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
});
|
|
|
|
return Promise.race([promise, timeout]).finally(() => {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function publicJson(
|
|
data: unknown,
|
|
{
|
|
cacheStatus,
|
|
cacheControl = "private, max-age=30, stale-while-revalidate=120",
|
|
fallback = false,
|
|
startedAt,
|
|
status,
|
|
}: {
|
|
cacheStatus: CacheStatus;
|
|
cacheControl?: string;
|
|
fallback?: boolean;
|
|
startedAt: number;
|
|
status?: number;
|
|
}
|
|
) {
|
|
const durationMs = Math.max(0, Date.now() - startedAt);
|
|
const response = NextResponse.json(data, status ? { status } : undefined);
|
|
response.headers.set("Cache-Control", cacheControl);
|
|
response.headers.set("X-Cache", cacheStatus);
|
|
response.headers.set("X-Fallback", fallback ? "1" : "0");
|
|
response.headers.set("Server-Timing", `app;dur=${durationMs}`);
|
|
return response;
|
|
}
|
|
|
|
export async function cachedPublicJson<T extends Record<string, unknown>>({
|
|
cacheKey,
|
|
fallbackData,
|
|
load,
|
|
timeoutMs = 1800,
|
|
ttlMs = 30_000,
|
|
staleMs = 5 * 60_000,
|
|
cacheControl,
|
|
}: {
|
|
cacheKey: string;
|
|
fallbackData: T;
|
|
load: () => Promise<T>;
|
|
timeoutMs?: number;
|
|
ttlMs?: number;
|
|
staleMs?: number;
|
|
cacheControl?: string;
|
|
}) {
|
|
const startedAt = Date.now();
|
|
const store = getStore<T>();
|
|
const cached = store.get(cacheKey);
|
|
|
|
if (cached && cached.expiresAt > startedAt) {
|
|
return publicJson(cached.data, {
|
|
cacheStatus: "HIT",
|
|
cacheControl,
|
|
startedAt,
|
|
});
|
|
}
|
|
|
|
try {
|
|
const data = await withTimeout(load(), timeoutMs, cacheKey);
|
|
store.set(cacheKey, {
|
|
data,
|
|
expiresAt: Date.now() + ttlMs,
|
|
staleUntil: Date.now() + ttlMs + staleMs,
|
|
});
|
|
return publicJson(data, {
|
|
cacheStatus: "MISS",
|
|
cacheControl,
|
|
startedAt,
|
|
});
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.warn(`[Public API] ${cacheKey} fallback: ${message}`);
|
|
|
|
if (cached && cached.staleUntil > Date.now()) {
|
|
return publicJson(cached.data, {
|
|
cacheStatus: "STALE",
|
|
cacheControl,
|
|
fallback: true,
|
|
startedAt,
|
|
});
|
|
}
|
|
|
|
return publicJson(fallbackData, {
|
|
cacheStatus: "FALLBACK",
|
|
cacheControl,
|
|
fallback: true,
|
|
startedAt,
|
|
});
|
|
}
|
|
}
|