/** * API Layer * * Modern fetch-based API layer with: * - Request deduplication (identical concurrent GETs share one promise) * - Loading indicator integration (requestsStore) * - AbortSignal support * - Build-version checking (X-Build-Time header) * - Sentry span instrumentation * - In-memory cache for getCachedEntries / getCachedEntry */ import { get } from "svelte/store" import { apiRequest, obj2str } from "../../../api/hooks/lib/ssr" import * as sentry from "../sentry" import { apiBaseOverride } from "./store" import { incrementRequests, decrementRequests } from "./requestsStore" import { setServerBuildTime } from "./serverBuildInfo" import { checkBuildVersion } from "./versionCheck" import { mockApiRequest } from "./mock" // --------------------------------------------------------------------------- // Request deduplication // --------------------------------------------------------------------------- const pendingRequests = new Map>>() /** * Generate a deterministic cache key for a request so identical parallel calls * collapse into a single network round-trip. */ const getRequestCacheKey = (endpoint: string, options?: ApiOptions, body?: unknown): string => { const deterministicStringify = (obj: unknown): string => { if (obj === null || obj === undefined) return String(obj) if (typeof obj !== "object") return JSON.stringify(obj) if (Array.isArray(obj)) return `[${obj.map(deterministicStringify).join(",")}]` const keys = Object.keys(obj).sort() const pairs = keys.map( (key) => `${JSON.stringify(key)}:${deterministicStringify((obj as Record)[key])}` ) return `{${pairs.join(",")}}` } return deterministicStringify({ endpoint, options, body }) } // --------------------------------------------------------------------------- // Core API function // --------------------------------------------------------------------------- /** * Execute an API request. * * - Identical concurrent GET requests are deduplicated automatically. * - Pass `signal` to opt-out of deduplication and enable cancellation. * - Drives the global loading indicator via `requestsStore`. * - Reads the `X-Build-Time` response header and triggers version check. * * @example * const { data, count } = await api("content", { filter: { lang: "de" } }) */ export const api = async ( endpoint: string, options?: ApiOptions, body?: unknown, signal?: AbortSignal ): Promise> => { const _apiBaseOverride = get(apiBaseOverride) || "" // Merge options – auth header injection can be added here later: // const authToken = getAccessToken() // ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}) const mergedHeaders = { ...(options?.headers || {}), } const mergedOptions: ApiOptions = { ...options, headers: mergedHeaders, signal } // Deduplication: skip when caller provides a signal (they want explicit control) const cacheKey = getRequestCacheKey(endpoint, options, body) if (!signal && pendingRequests.has(cacheKey)) { return pendingRequests.get(cacheKey) as Promise> } const requestPromise = (async () => { try { if (signal?.aborted) { throw new DOMException("Aborted", "AbortError") } incrementRequests() // ── Mock interceptor (tree-shaken when __MOCK__ is false) ── if (__MOCK__) { const mockResult = mockApiRequest(endpoint, mergedOptions, body) if (mockResult) return mockResult as ApiResult // No mock data for this endpoint → 404 throw { response: { status: 404 }, data: { error: `[mock] No mock data for "${endpoint}"` } } } const data = await apiRequest(_apiBaseOverride + endpoint, mergedOptions, body, sentry) // Build-version check only on GETs (don't reload mid-write) const method = mergedOptions?.method?.toUpperCase() || "GET" if (method === "GET" && data?.buildTime) { setServerBuildTime(data.buildTime) checkBuildVersion(data.buildTime) } return data as ApiResult } catch (err) { // Don't send abort errors to Sentry if (err instanceof DOMException && err.name === "AbortError") throw err // Auth: 401 handling placeholder // const status = (err as { response?: { status?: number } })?.response?.status // if (status === 401) { /* refresh token + retry */ } throw err } finally { if (!signal) { pendingRequests.delete(cacheKey) } decrementRequests() } })() if (!signal) { pendingRequests.set(cacheKey, requestPromise) } return requestPromise } // --------------------------------------------------------------------------- // In-memory response cache (for getCachedEntries / getCachedEntry) // --------------------------------------------------------------------------- const cache: Record = {} const CACHE_TTL = 1000 * 60 * 60 // 1 hour // --------------------------------------------------------------------------- // Generic collection helpers // --------------------------------------------------------------------------- type CollectionNameT = "medialib" | "content" | string type EntryTypeSwitch = T extends "medialib" ? MedialibEntry : T extends "content" ? ContentEntry : Record export async function getDBEntries( collectionName: T, filter?: MongoFilter, sort: string = "sort", limit?: number, offset?: number, projection?: string, params?: Record ): Promise[]> { const c = await api[]>(collectionName, { filter, sort, limit, offset, projection, params, }) return c.data } export async function getCachedEntries( collectionName: T, filter?: MongoFilter, sort: string = "sort", limit?: number, offset?: number, projection?: string, params?: Record ): Promise[]> { const filterStr = obj2str({ collectionName, filter, sort, limit, offset, projection, params }) if (cache[filterStr] && cache[filterStr].expire >= Date.now()) { return cache[filterStr].data as EntryTypeSwitch[] } const entries = await getDBEntries(collectionName, filter, sort, limit, offset, projection, params) cache[filterStr] = { expire: Date.now() + CACHE_TTL, data: entries } return entries } export async function getDBEntry( collectionName: T, filter: MongoFilter, projection?: string, params?: Record ): Promise | undefined> { return (await getDBEntries(collectionName, filter, "_id", 1, undefined, projection, params))?.[0] } export async function getCachedEntry( collectionName: T, filter: MongoFilter, projection?: string, params?: Record ): Promise | undefined> { return (await getCachedEntries(collectionName, filter, "_id", 1, undefined, projection, params))?.[0] } export async function postDBEntry( collectionName: T, entry: EntryTypeSwitch ): Promise>> { return api>(collectionName, { method: "POST" }, entry) }