Add client-side caching for public reads

This commit is contained in:
Your Name 2025-12-30 16:37:05 -05:00
parent 59d2aa27f1
commit 25f614f4e2
2 changed files with 140 additions and 85 deletions

View File

@ -4,7 +4,22 @@
import { loadAuth } from "../auth/authStorage";
const API_BASE = "/api";
function apiBase() {
const raw =
(import.meta?.env?.VITE_API_BASE_URL || "")
.toString()
.replace(/\/+$/, "");
if (raw) return raw + "/api";
try {
if (typeof window !== "undefined") {
const host = window.location.hostname || "";
if (host.startsWith("www.")) {
return `https://${host.replace(/^www\./, "")}/api`;
}
}
} catch {}
return "/api";
}
function authHeaders() {
try {
@ -17,6 +32,45 @@ function authHeaders() {
}
}
const responseCache = new Map();
function cacheKeyFor(url, headers = {}) {
const auth = headers?.Authorization || headers?.authorization;
if (auth) return ""; // avoid caching authenticated responses
return `GET:${url}`;
}
function cloneJson(value) {
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
async function fetchJsonCached(url, { ttlMs = 0, headers = {}, credentials = "include" } = {}) {
const key = cacheKeyFor(url, headers);
if (ttlMs > 0 && key) {
const hit = responseCache.get(key);
if (hit && hit.expiresAt > Date.now()) {
return cloneJson(hit.value);
}
}
const res = await fetch(url, { credentials, headers });
if (!res.ok) {
const text = await res.text().catch(() => "");
const err = new Error(text || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
const data = await res.json().catch(() => null);
if (ttlMs > 0 && key) {
responseCache.set(key, { value: data, expiresAt: Date.now() + ttlMs });
}
return cloneJson(data);
}
function isAssetRef(value) {
return typeof value === "string" && value.startsWith("asset://");
@ -27,7 +81,7 @@ async function resolveAssetURLs(ids) {
const qs = new URLSearchParams();
qs.set("ids", ids.join(","));
try {
const res = await fetch(`${API_BASE}/assets/resolve?${qs.toString()}`, {
const res = await fetch(`${apiBase()}/assets/resolve?${qs.toString()}`, {
credentials: "include",
headers: { ...authHeaders() },
});
@ -115,20 +169,16 @@ export async function fetchPosts(filters = {}) {
}
const qs = params.toString();
const url = qs ? `${API_BASE}/posts?${qs}` : `${API_BASE}/posts`;
const url = qs ? `${apiBase()}/posts?${qs}` : `${apiBase()}/posts`;
const res = await fetch(url, {
credentials: "include",
});
if (!res.ok) {
console.warn("fetchPosts failed (treated as empty)", res.status);
try {
const data = await fetchJsonCached(url, { ttlMs: 15000 });
const items = Array.isArray(data) ? data : data?.items || [];
return await resolveAssetRefsInPosts(items);
} catch (err) {
console.warn("fetchPosts failed (treated as empty)", err?.status || err);
return [];
}
const data = await res.json();
const items = Array.isArray(data) ? data : data.items || [];
return await resolveAssetRefsInPosts(items);
}
/**
@ -136,27 +186,8 @@ export async function fetchPosts(filters = {}) {
*/
export async function fetchIpLocation() {
try {
const res = await fetch(`${API_BASE}/ip-location`, {
credentials: "include",
});
if (!res.ok) {
console.warn("fetchIpLocation: HTTP", res.status);
return null;
}
const text = await res.text();
if (!text || text === "null") {
return null;
}
let data;
try {
data = JSON.parse(text);
} catch (e) {
console.warn("fetchIpLocation: JSON parse error", e, text);
return null;
}
const data = await fetchJsonCached(`${apiBase()}/ip-location`, { ttlMs: 10 * 60 * 1000 });
if (!data || data === "null") return null;
const lat = data.lat;
const lon = data.lon;
@ -190,7 +221,7 @@ export async function createPost(payload, token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}/post`, {
const res = await fetch(`${apiBase()}/post`, {
method: "POST",
headers,
credentials: "include",
@ -235,7 +266,7 @@ export async function fetchPostPreview(url, token) {
if (token) headers["Authorization"] = `Bearer ${token}`;
else Object.assign(headers, authHeaders());
const res = await fetch(`${API_BASE}/post/preview`, {
const res = await fetch(`${apiBase()}/post/preview`, {
method: "POST",
headers,
credentials: "include",
@ -260,11 +291,7 @@ export async function fetchPostById(postId) {
const qs = new URLSearchParams();
qs.set("id", String(postId));
try {
const res = await fetch(`${API_BASE}/post?${qs.toString()}`, {
credentials: "include",
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 });
if (!data || typeof data !== "object") return null;
const resolved = await resolveAssetRefsInPosts([data]);
return resolved && resolved[0] ? resolved[0] : data;
@ -279,11 +306,7 @@ export async function fetchPostByUrl(url) {
const qs = new URLSearchParams();
qs.set("url", raw);
try {
const res = await fetch(`${API_BASE}/post?${qs.toString()}`, {
credentials: "include",
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
const data = await fetchJsonCached(`${apiBase()}/post?${qs.toString()}`, { ttlMs: 15000 });
if (!data || typeof data !== "object") return null;
const resolved = await resolveAssetRefsInPosts([data]);
return resolved && resolved[0] ? resolved[0] : data;
@ -297,7 +320,7 @@ export async function fetchPostTruth(postId) {
const qs = new URLSearchParams();
qs.set("post_id", String(postId));
try {
const res = await fetch(`${API_BASE}/post/truth?${qs.toString()}`, {
const res = await fetch(`${apiBase()}/post/truth?${qs.toString()}`, {
credentials: "include",
headers: { ...authHeaders() },
});
@ -312,7 +335,7 @@ export async function fetchPostTruth(postId) {
export async function votePostTruth(postId, vote) {
if (!postId) throw new Error("post_id required");
const body = JSON.stringify({ post_id: postId, vote });
const res = await fetch(`${API_BASE}/post/truth-vote`, {
const res = await fetch(`${apiBase()}/post/truth-vote`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
@ -331,7 +354,7 @@ export async function updatePostVisibility(postId, payload = {}, token) {
const headers = { "Content-Type": "application/json" };
if (token) headers["Authorization"] = `Bearer ${token}`;
const body = JSON.stringify({ post_id: postId, ...payload });
const res = await fetch(`${API_BASE}/post/visibility`, {
const res = await fetch(`${apiBase()}/post/visibility`, {
method: "PATCH",
headers,
credentials: "include",
@ -352,7 +375,7 @@ export async function updatePostVisibility(postId, payload = {}, token) {
* payload: { username, email, password, firstName, lastName }
*/
export async function registerUser(payload) {
const res = await fetch(`${API_BASE}/signup`, {
const res = await fetch(`${apiBase()}/signup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -397,15 +420,10 @@ export async function fetchSmartFeed(params = {}) {
if (maxLon != null) qs.set("maxLon", String(maxLon));
}
const url = `${API_BASE}/smart-feed?${qs.toString()}`;
const url = `${apiBase()}/smart-feed?${qs.toString()}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("fetchSmartFeed failed", res.status);
return [];
}
const data = await res.json();
const data = await fetchJsonCached(url, { ttlMs: 10000 });
const items = Array.isArray(data) ? data : data.results || data.items || [];
return await resolveAssetRefsInPosts(items);
} catch (err) {
@ -428,15 +446,10 @@ export async function fetchUnifiedSearch(query, params = {}) {
if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km));
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
const url = `${API_BASE}/unified-search?${qs.toString()}`;
const url = `${apiBase()}/unified-search?${qs.toString()}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
console.warn("fetchUnifiedSearch failed", res.status);
return { posts: [], profiles: [], places: [] };
}
const data = await res.json();
const data = await fetchJsonCached(url, { ttlMs: 15000 });
if (data && typeof data === "object" && !Array.isArray(data)) {
if (Array.isArray(data.posts)) {
data.posts = await resolveAssetRefsInPosts(data.posts);
@ -464,7 +477,7 @@ export async function pollNewPosts(params = {}) {
if (params.category) qs.set("category", params.category);
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
const url = `${API_BASE}/poll?${qs.toString()}`;
const url = `${apiBase()}/poll?${qs.toString()}`;
try {
const res = await fetch(url, { credentials: "include" });
@ -498,7 +511,7 @@ export async function fetchIslands(params = {}) {
if (typeof params.radius_km === "number") qs.set("radius_km", String(params.radius_km));
if (typeof params.limit === "number") qs.set("limit", String(params.limit));
const url = `${API_BASE}/islands/nearby?${qs.toString()}`;
const url = `${apiBase()}/islands/nearby?${qs.toString()}`;
try {
const res = await fetch(url, { credentials: "include" });
@ -523,7 +536,7 @@ export async function fetchIslands(params = {}) {
*/
export async function fetchIsland(islandId) {
if (!islandId) return null;
const url = `${API_BASE}/islands/${encodeURIComponent(islandId)}`;
const url = `${apiBase()}/islands/${encodeURIComponent(islandId)}`;
try {
const res = await fetch(url, { credentials: "include" });
@ -545,7 +558,7 @@ export async function fetchIsland(islandId) {
export async function fetchIslandByUsername(username) {
if (!username) return null;
const url = `${API_BASE}/islands/by-username?username=${encodeURIComponent(username)}`;
const url = `${apiBase()}/islands/by-username?username=${encodeURIComponent(username)}`;
try {
const res = await fetch(url, { credentials: "include" });
if (!res.ok) {
@ -566,7 +579,7 @@ export async function fetchIslandByUsername(username) {
export async function uploadProfileAvatar(file, opts = {}) {
if (!file) return { ok: false };
const url = `${API_BASE}/profile/avatar`;
const url = `${apiBase()}/profile/avatar`;
const form = new FormData();
form.append("file", file);
if (opts.visibility) form.append("visibility", opts.visibility);
@ -595,7 +608,7 @@ export async function uploadProfileAvatar(file, opts = {}) {
}
export async function updateProfileUsername(username) {
const url = `${API_BASE}/profile/username`;
const url = `${apiBase()}/profile/username`;
try {
const res = await fetch(url, {
method: "POST",
@ -620,7 +633,7 @@ export async function updateProfileUsername(username) {
export async function fetchProfile(username = "") {
const qs = new URLSearchParams();
if (username) qs.set("username", username);
const url = qs.toString() ? `${API_BASE}/profile?${qs}` : `${API_BASE}/profile`;
const url = qs.toString() ? `${apiBase()}/profile?${qs}` : `${apiBase()}/profile`;
try {
const res = await fetch(url, {
credentials: "include",
@ -643,7 +656,7 @@ export async function fetchProfile(username = "") {
export async function recordPostView(postId) {
if (!postId) return { ok: false };
try {
const res = await fetch(`${API_BASE}/post/view`, {
const res = await fetch(`${apiBase()}/post/view`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@ -659,7 +672,7 @@ export async function recordPostView(postId) {
export async function togglePostLike(postId, like = true) {
if (!postId) return { ok: false };
try {
const res = await fetch(`${API_BASE}/post/like`, {
const res = await fetch(`${apiBase()}/post/like`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
@ -675,7 +688,7 @@ export async function togglePostLike(postId, like = true) {
export async function recordPostShare(postId) {
if (!postId) return { ok: false };
try {
const res = await fetch(`${API_BASE}/post/share`, {
const res = await fetch(`${apiBase()}/post/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@ -694,7 +707,7 @@ export async function fetchPostComments(postId, limit = 20) {
qs.set("post_id", String(postId));
if (limit) qs.set("limit", String(limit));
try {
const res = await fetch(`${API_BASE}/post/comments?${qs.toString()}`, {
const res = await fetch(`${apiBase()}/post/comments?${qs.toString()}`, {
credentials: "include",
});
if (!res.ok) return [];
@ -708,7 +721,7 @@ export async function fetchPostComments(postId, limit = 20) {
export async function createPostComment(postId, body) {
if (!postId || !body) return { ok: false };
try {
const res = await fetch(`${API_BASE}/post/comment`, {
const res = await fetch(`${apiBase()}/post/comment`, {
method: "POST",
headers: { "Content-Type": "application/json", ...authHeaders() },
credentials: "include",
@ -726,7 +739,7 @@ export async function fetchPostLikeState(postId) {
const qs = new URLSearchParams();
qs.set("post_id", String(postId));
try {
const res = await fetch(`${API_BASE}/post/like-state?${qs.toString()}`, {
const res = await fetch(`${apiBase()}/post/like-state?${qs.toString()}`, {
credentials: "include",
headers: { ...authHeaders() },
});

View File

@ -10,15 +10,51 @@
* - Ton search bar actuelle filtre déjà localement (0 call réseau).
*/
function apiPrefix() {
const raw =
(import.meta?.env?.VITE_API_BASE_URL || "")
.toString()
.replace(/\/+$/, "");
if (raw) return raw + "/api";
try {
if (typeof window !== "undefined") {
const host = window.location.hostname || "";
if (host.startsWith("www.")) {
return `https://${host.replace(/^www\./, "")}/api`;
}
}
} catch {}
return "/api";
}
const DEFAULT_PATHS = [
"/api/suggestions", // NEW: Autocomplete suggestions (cities, posts, profiles)
"/api/unified-search", // Unified search (posts + profiles + places)
"/api/search", // Fallback: Posts only
"/api/reco/search",
"/api/reco/suggest",
"/api/recommendations/search",
"/suggestions", // NEW: Autocomplete suggestions (cities, posts, profiles)
"/unified-search", // Unified search (posts + profiles + places)
"/search", // Fallback: Posts only
"/reco/search",
"/reco/suggest",
"/recommendations/search",
];
const searchCache = new Map();
function cacheGet(query) {
const key = query.toLowerCase().trim();
const hit = searchCache.get(key);
if (!hit) return null;
if (hit.expiresAt <= Date.now()) {
searchCache.delete(key);
return null;
}
return hit.value;
}
function cacheSet(query, value, ttlMs) {
const key = query.toLowerCase().trim();
if (!key) return;
searchCache.set(key, { value, expiresAt: Date.now() + ttlMs });
}
/**
* Normalise un item venant du core-api.
* On reste permissif: Rico / core-api peut renvoyer plusieurs shapes.
@ -70,6 +106,8 @@ function normalizeOne(r) {
async function tryFetchAnyPath(q, { signal } = {}) {
const query = (q || "").toString().trim();
if (!query) return [];
const cached = cacheGet(query);
if (cached) return cached;
// Permet override runtime si tu veux:
// window.__SW_RECO_SEARCH_PATH = "/api/reco/search"
@ -78,11 +116,13 @@ async function tryFetchAnyPath(q, { signal } = {}) {
? String(window.__SW_RECO_SEARCH_PATH)
: "";
const prefix = apiPrefix();
const paths = override ? [override, ...DEFAULT_PATHS] : DEFAULT_PATHS;
for (const p of paths) {
try {
const url = `${p}?q=${encodeURIComponent(query)}`;
const base = p.startsWith("http") ? p : `${prefix}${p}`;
const url = `${base}?q=${encodeURIComponent(query)}`;
console.log('[recoSearch] Trying:', url);
const res = await fetch(url, { credentials: "include", signal });
@ -106,7 +146,9 @@ async function tryFetchAnyPath(q, { signal } = {}) {
: [];
console.log('[recoSearch] Success! Returning', arr.length, 'results from', p);
return arr.map(normalizeOne).filter(Boolean);
const out = arr.map(normalizeOne).filter(Boolean);
cacheSet(query, out, 30 * 1000);
return out;
} catch (e) {
// Abort => stop net
if (e && (e.name === "AbortError" || e.code === 20)) {