Files
tibi-svelte-starter/frontend/src/lib/api.ts

210 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, Promise<ApiResult<unknown>>>()
/**
* 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<string, unknown>)[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<ContentEntry[]>("content", { filter: { lang: "de" } })
*/
export const api = async <T>(
endpoint: string,
options?: ApiOptions,
body?: unknown,
signal?: AbortSignal
): Promise<ApiResult<T>> => {
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<ApiResult<T>>
}
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<T>
// 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<T>
} 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<string, { expire: number; data: unknown }> = {}
const CACHE_TTL = 1000 * 60 * 60 // 1 hour
// ---------------------------------------------------------------------------
// Generic collection helpers
// ---------------------------------------------------------------------------
type CollectionNameT = "medialib" | "content" | string
type EntryTypeSwitch<T extends string> = T extends "medialib"
? MedialibEntry
: T extends "content"
? ContentEntry
: Record<string, unknown>
export async function getDBEntries<T extends CollectionNameT>(
collectionName: T,
filter?: MongoFilter,
sort: string = "sort",
limit?: number,
offset?: number,
projection?: string,
params?: Record<string, string>
): Promise<EntryTypeSwitch<T>[]> {
const c = await api<EntryTypeSwitch<T>[]>(collectionName, {
filter,
sort,
limit,
offset,
projection,
params,
})
return c.data
}
export async function getCachedEntries<T extends CollectionNameT>(
collectionName: T,
filter?: MongoFilter,
sort: string = "sort",
limit?: number,
offset?: number,
projection?: string,
params?: Record<string, string>
): Promise<EntryTypeSwitch<T>[]> {
const filterStr = obj2str({ collectionName, filter, sort, limit, offset, projection, params })
if (cache[filterStr] && cache[filterStr].expire >= Date.now()) {
return cache[filterStr].data as EntryTypeSwitch<T>[]
}
const entries = await getDBEntries<T>(collectionName, filter, sort, limit, offset, projection, params)
cache[filterStr] = { expire: Date.now() + CACHE_TTL, data: entries }
return entries
}
export async function getDBEntry<T extends CollectionNameT>(
collectionName: T,
filter: MongoFilter,
projection?: string,
params?: Record<string, string>
): Promise<EntryTypeSwitch<T> | undefined> {
return (await getDBEntries<T>(collectionName, filter, "_id", 1, undefined, projection, params))?.[0]
}
export async function getCachedEntry<T extends CollectionNameT>(
collectionName: T,
filter: MongoFilter,
projection?: string,
params?: Record<string, string>
): Promise<EntryTypeSwitch<T> | undefined> {
return (await getCachedEntries<T>(collectionName, filter, "_id", 1, undefined, projection, params))?.[0]
}
export async function postDBEntry<T extends CollectionNameT>(
collectionName: T,
entry: EntryTypeSwitch<T>
): Promise<ApiResult<EntryTypeSwitch<T>>> {
return api<EntryTypeSwitch<T>>(collectionName, { method: "POST" }, entry)
}