diff --git a/api/hooks/lib/ssr.js b/api/hooks/lib/ssr.js index eebb807..650ffd2 100644 --- a/api/hooks/lib/ssr.js +++ b/api/hooks/lib/ssr.js @@ -40,13 +40,10 @@ function obj2str(obj) { * @param {string} endpoint * @param {ApiOptions} options * @param {any} body - * @param {import("../../../frontend/src/sentry")} sentry - * @param {typeof fetch} _fetch + * @param {any} sentry * @returns {Promise>} */ -function apiRequest(endpoint, options, body, sentry, _fetch) { - // TODO cache only for GET - +function apiRequest(endpoint, options, body, sentry) { // first check cache if on client const cacheKey = obj2str({ endpoint: endpoint, options: options }) @@ -64,7 +61,13 @@ function apiRequest(endpoint, options, body, sentry, _fetch) { let query = "&count=1" if (options?.filter) query += "&filter=" + encodeURIComponent(JSON.stringify(options.filter)) - if (options?.sort) query += "&sort=" + options.sort + "&sort=_id" + if (options?.sort) { + query += "&sort=" + options.sort + // Only append _id as secondary sort if not already sorting by _id + if (options.sort !== "_id" && options.sort !== "-_id") { + query += "&sort=_id" + } + } if (options?.limit) query += "&limit=" + options.limit if (options?.offset) query += "&offset=" + options.offset if (options?.projection) query += "&projection=" + options.projection @@ -94,12 +97,12 @@ function apiRequest(endpoint, options, body, sentry, _fetch) { // client let url = (endpoint.startsWith("/") ? "" : apiClientBaseURL) + endpoint + (query ? "?" + query : "") - const span = sentry?.currentTransaction()?.startChild({ + const span = sentry?.startChildSpan?.({ op: "fetch", - description: method + " " + url, - data: Object.assign({}, options, { url }), + name: method + " " + url, + attributes: Object.assign({}, options, { url }), }) - const trace_id = span?.toTraceparent() + const trace_id = span ? sentry?.spanToTraceHeader?.(span) : undefined if (trace_id) { headers["sentry-trace"] = trace_id } @@ -108,23 +111,42 @@ function apiRequest(endpoint, options, body, sentry, _fetch) { const requestOptions = { method, mode: "cors", + credentials: "include", headers, } + // Add AbortSignal if provided + if (options?.signal) { + requestOptions.signal = options.signal + } + if (method === "POST" || method === "PUT") { requestOptions.body = JSON.stringify(body) } - const response = _fetch(url, requestOptions).then((response) => { - return response?.json().then((json) => { - if (response?.status < 200 || response?.status >= 400) { - return Promise.reject({ response, data: json }) - } - return Promise.resolve({ data: json || null, count: response.headers?.get("x-results-count") || 0 }) + const response = fetch(url, requestOptions) + .then((response) => { + return response?.json().then((json) => { + span?.end() + if (response?.status < 200 || response?.status >= 400) { + return Promise.reject({ response, data: json }) + } + return Promise.resolve({ + data: json || null, + count: response.headers?.get("x-results-count") || 0, + buildTime: response.headers?.get("x-build-time") || null, + }) + }) + }) + .catch((error) => { + span?.end() + // Re-throw AbortError as-is so it can be handled upstream + if (error?.name === "AbortError") { + console.log("🚫 Request aborted:", url) + throw error + } + throw error }) - }) - - span?.end() // @ts-ignore return response diff --git a/frontend/src/api.ts b/frontend/src/api.ts deleted file mode 100644 index b7093fa..0000000 --- a/frontend/src/api.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { get } from "svelte/store" -import { apiRequest, obj2str } from "../../api/hooks/lib/ssr" -import * as sentry from "./sentry" -import { apiBaseOverride } from "./lib/store" - -// fetch polyfill -// [MIT License](LICENSE.md) © [Jason Miller](https://jasonformat.com/) -const _f = function (url: string, options?: { [key: string]: any }) { - if (typeof XMLHttpRequest === "undefined") { - return Promise.resolve(null) - } - - options = options || {} - return new Promise((resolve, reject) => { - const request = new XMLHttpRequest() - const keys: string[] = [] - // @ts-ignore - const all = [] - const headers = {} - - const response = () => ({ - ok: ((request.status / 100) | 0) == 2, // 200-299 - statusText: request.statusText, - status: request.status, - url: request.responseURL, - text: () => Promise.resolve(request.responseText), - json: () => Promise.resolve(request.responseText).then(JSON.parse), - blob: () => Promise.resolve(new Blob([request.response])), - clone: response, - headers: { - // @ts-ignore - keys: () => keys, - // @ts-ignore - entries: () => all, - // @ts-ignore - get: (n) => headers[n.toLowerCase()], - // @ts-ignore - has: (n) => n.toLowerCase() in headers, - }, - }) - - request.open(options.method || "get", url, true) - - request.onload = () => { - request - .getAllResponseHeaders() - // @ts-ignore - .replace(/^(.*?):[^\S\n]*([\s\S]*?)$/gm, (m, key, value) => { - keys.push((key = key.toLowerCase())) - all.push([key, value]) - // @ts-ignore - headers[key] = headers[key] ? `${headers[key]},${value}` : value - }) - resolve(response()) - } - - request.onerror = reject - - request.withCredentials = options.credentials == "include" - - for (const i in options.headers) { - request.setRequestHeader(i, options.headers[i]) - } - - request.send(options.body || null) - }) -} - -// fetch must be declared after sentry import to get the hijacked fetch -// @ts-ignore -export const _fetch: typeof fetch = - typeof fetch === "undefined" ? (typeof window === "undefined" ? _f : window.fetch || _f) : fetch - -export const api = async ( - endpoint: string, - options?: ApiOptions, - body?: any -): Promise<{ data: T; count: number } | any> => { - const _apiBaseOverride = get(apiBaseOverride) || "" - let data = await apiRequest(_apiBaseOverride + endpoint, options, body, sentry, _fetch) - // @ts-ignore - // console.log(data, "data") - return data -} - -const cache: { - [key: string]: { - expire: number - data: any - } -} = {} - -type CollectionNameT = "medialib" | "content" | "product" - -type EntryTypeSwitch = T extends "medialib" - ? MedialibEntry - : T extends "content" - ? ContentEntry - : T extends "product" - ? ProductEntry - : never - -export async function getDBEntries( - collectionName: T, - filter?: { [key: string]: any }, - sort: string = "sort", - limit?: number, - offset?: number, - projection?: string, - params?: { [key: string]: string } -): Promise[]> { - const c = await api[]>(collectionName, { - filter, - sort, - limit, - offset, - projection, - params, - }) - return c.data -} - -export async function getCachedEntries( - collectionName: T, - filter?: { [key: string]: any }, - sort: string = "sort", - limit?: number, - offset?: number, - projection?: string, - params?: { [key: string]: string } -): Promise[]> { - const filterStr = obj2str({ collectionName, filter, sort, limit, offset, projection, params }) - if (cache[filterStr] && cache[filterStr].expire >= Date.now()) { - return cache[filterStr].data - } - const entries = await getDBEntries(collectionName, filter, sort, limit, offset, projection, params) - // expire in 1h - cache[filterStr] = { expire: Date.now() + 1000 * 60 * 60, data: entries } - return entries -} - -export async function getDBEntry( - collectionName: T, - filter: { [key: string]: any }, - params?: { [key: string]: string } -) { - return (await getDBEntries(collectionName, filter, "_id", null, null, null, params))?.[0] -} - -export async function getCachedEntry( - collectionName: T, - filter: { [key: string]: any }, - params?: { [key: string]: string } -) { - return (await getCachedEntries(collectionName, filter, "_id", null, null, null, params))?.[0] -} - -export async function postDBEntry(collectionName: T, entry: EntryTypeSwitch) { - return api>(collectionName, { method: "POST" }, entry) -} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..d2ec715 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,198 @@ +/** + * 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" + +// --------------------------------------------------------------------------- +// 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?: any): string => { + const deterministicStringify = (obj: any): 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[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?: any, + 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)! + } + + const requestPromise = (async () => { + try { + if (signal?.aborted) { + throw new DOMException("Aborted", "AbortError") + } + incrementRequests() + + 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 as any)?.name === "AbortError") throw err + + // Auth: 401 handling placeholder + // const status = (err as any)?.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: { [key: string]: { expire: number; data: any } } = {} +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?: { [key: string]: any }, + sort: string = "sort", + limit?: number, + offset?: number, + projection?: string, + params?: { [key: string]: string } +): Promise[]> { + const c = await api[]>(collectionName, { + filter, + sort, + limit, + offset, + projection, + params, + }) + return c.data +} + +export async function getCachedEntries( + collectionName: T, + filter?: { [key: string]: any }, + sort: string = "sort", + limit?: number, + offset?: number, + projection?: string, + params?: { [key: string]: string } +): Promise[]> { + const filterStr = obj2str({ collectionName, filter, sort, limit, offset, projection, params }) + if (cache[filterStr] && cache[filterStr].expire >= Date.now()) { + return cache[filterStr].data + } + 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: { [key: string]: any }, + projection?: string, + params?: { [key: string]: string } +) { + return (await getDBEntries(collectionName, filter, "_id", 1, null, projection, params))?.[0] +} + +export async function getCachedEntry( + collectionName: T, + filter: { [key: string]: any }, + projection?: string, + params?: { [key: string]: string } +) { + return (await getCachedEntries(collectionName, filter, "_id", 1, null, projection, params))?.[0] +} + +export async function postDBEntry( + collectionName: T, + entry: EntryTypeSwitch +): Promise>> { + return api>(collectionName, { method: "POST" }, entry) +}