diff --git a/end_user/composables/useDataCache.ts b/end_user/composables/useDataCache.ts new file mode 100644 index 0000000..15e4135 --- /dev/null +++ b/end_user/composables/useDataCache.ts @@ -0,0 +1,224 @@ +import { devLog } from '~/utils/logger' + +/** + * Composable for managing a session-based data cache that bridges SSR and Client-side. + * This cache uses Nuxt's `useState` to transfer data from server to client during hydration, + * and `sessionStorage` to persist that data across page navigations in the same browser session. + * + * Cache entries expire after 30 minutes to prevent stale data. + */ +export const useDataCache = () => { + /** + * Cache TTL in milliseconds (30 minutes) + */ + const CACHE_TTL = 30 * 60 * 1000; // 30 minutes + + /** + * The SSR bridge state. Nuxt automatically serializes this into the page payload. + * Format: { [cacheKey: string]: { data: any, timestamp: number } } + * Note: This persists during the session for client-side navigation. It's synced to + * sessionStorage on mount but kept in memory for fast access during navigation. + */ + const ssrBridge = useState>('ssr-data-cache', () => ({})); + + /** + * Generates a stable unique cache key from request parameters. + * Ensures that object property order doesn't affect the resulting key. + * + * @param params - The parameters used for the dataProvider request + * @returns A stable stringified hash-like key + */ + const generateKey = (params: any): string => { + // Stable stringify to handle object property order + const stableStringify = (obj: any): string => { + if (obj === null || typeof obj !== 'object') { + return String(obj); + } + if (Array.isArray(obj)) { + return '[' + obj.map(stableStringify).join(',') + ']'; + } + const keys = Object.keys(obj).sort(); + return '{' + keys.map(k => `${k}:${stableStringify(obj[k])}`).join(',') + '}'; + }; + + return stableStringify(params); + }; + + /** + * Checks if a cache entry is still valid (not expired). + * + * @param timestamp - The timestamp when the entry was cached + * @returns true if the entry is still valid, false if expired + */ + const isCacheValid = (timestamp: number): boolean => { + const now = Date.now(); + return (now - timestamp) < CACHE_TTL; + }; + + /** + * Retrieves data from the multi-layer cache. + * Layer 1: Browser sessionStorage (available on both SSR and client) + * Layer 2: Nuxt useState SSR bridge (for hydration and client-side navigation) + * + * @param key - The cache key generated by generateKey + * @returns The cached data or null if not found or expired + */ + const getCachedData = (key: string): any | null => { + // 1. Check sessionStorage first (works on both SSR hydration and client-side navigation) + // Note: sessionStorage is only available on client, but we check it first when available + if (import.meta.client && typeof sessionStorage !== 'undefined') { + try { + const stored = sessionStorage.getItem(`mr-cache:${key}`); + if (stored) { + const parsed = JSON.parse(stored); + // Check if it's the new format with timestamp + if (parsed && typeof parsed === 'object' && 'timestamp' in parsed && 'data' in parsed) { + if (isCacheValid(parsed.timestamp)) { + devLog('Cache', `Hit (SessionStorage): ${key.substring(0, 40)}...`); + // Also update SSR bridge for faster subsequent access + ssrBridge.value[key] = parsed; + return parsed.data; + } else { + // Expired - remove it + sessionStorage.removeItem(`mr-cache:${key}`); + devLog('Cache', `Expired entry removed: ${key.substring(0, 40)}...`); + } + } else { + // Legacy format (no timestamp) - treat as expired + sessionStorage.removeItem(`mr-cache:${key}`); + devLog('Cache', `Legacy entry removed: ${key.substring(0, 40)}...`); + } + } + } catch (e) { + console.warn('[useDataCache] Failed to read from sessionStorage', e); + } + } + + // 2. Check the SSR bridge (for SSR hydration and client-side navigation) + const ssrEntry = ssrBridge.value[key]; + if (ssrEntry && isCacheValid(ssrEntry.timestamp)) { + devLog('Cache', `Hit (SSR Bridge): ${key.substring(0, 40)}...`); + return ssrEntry.data; + } else if (ssrEntry) { + // Expired entry in SSR bridge - remove it + delete ssrBridge.value[key]; + } + + return null; + }; + + /** + * Stores data in the multi-layer cache with timestamp. + * + * @param key - The cache key generated by generateKey + * @param data - The data to cache + */ + const setCachedData = (key: string, data: any): void => { + devLog('Cache', `Storing: ${key.substring(0, 40)}...`); + const timestamp = Date.now(); + const cacheEntry = { data, timestamp }; + + // Store in SSR bridge (only for current request hydration) + ssrBridge.value[key] = cacheEntry; + + // Store in sessionStorage if on client side + if (import.meta.client) { + try { + sessionStorage.setItem(`mr-cache:${key}`, JSON.stringify(cacheEntry)); + } catch (e) { + // Handle QuotaExceededError or other storage issues + console.warn('[useDataCache] Failed to write to sessionStorage', e); + } + } + }; + + /** + * Cleans up expired entries from sessionStorage. + */ + const cleanupExpiredEntries = (): void => { + if (!import.meta.client) return; + + try { + const keysToRemove: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key && key.startsWith('mr-cache:')) { + try { + const stored = sessionStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + if (parsed && typeof parsed === 'object' && 'timestamp' in parsed) { + if (!isCacheValid(parsed.timestamp)) { + keysToRemove.push(key); + } + } else { + // Legacy format - remove it + keysToRemove.push(key); + } + } + } catch (e) { + // Invalid entry - remove it + keysToRemove.push(key); + } + } + } + keysToRemove.forEach(key => sessionStorage.removeItem(key)); + if (keysToRemove.length > 0) { + devLog('Cache', `Cleaned up ${keysToRemove.length} expired entries`); + } + } catch (e) { + console.warn('[useDataCache] Cleanup failed', e); + } + }; + + /** + * Flushes valid data from the SSR bridge into sessionStorage. + * Keeps SSR bridge available for client-side navigation (it persists during session). + * Usually called once when the app is mounted on the client. + */ + const syncSsrToSessionStorage = (): void => { + if (!import.meta.client) return; + + // Clean up expired entries first + cleanupExpiredEntries(); + + const entries = Object.entries(ssrBridge.value); + let syncedCount = 0; + + entries.forEach(([key, entry]) => { + // Only sync valid (non-expired) entries + if (isCacheValid(entry.timestamp)) { + try { + const storageKey = `mr-cache:${key}`; + // Only sync if not already present (avoid overwriting newer data) + const existing = sessionStorage.getItem(storageKey); + if (!existing) { + sessionStorage.setItem(storageKey, JSON.stringify(entry)); + syncedCount++; + } + } catch (e) { + console.warn('[useDataCache] Sync failed for key:', key, e); + } + } else { + // Remove expired entries from SSR bridge + delete ssrBridge.value[key]; + } + }); + + if (syncedCount > 0) { + devLog('Cache', `Synced ${syncedCount} items from SSR to SessionStorage`); + } + + // Note: We keep SSR bridge available for client-side navigation + // It will naturally clear when the page reloads or tab closes + // This allows instant cache hits during client-side navigation + }; + + return { + generateKey, + getCachedData, + setCachedData, + syncSsrToSessionStorage, + }; +}; + diff --git a/end_user/layouts/default.vue b/end_user/layouts/default.vue index 1b0d3d1..c475835 100644 --- a/end_user/layouts/default.vue +++ b/end_user/layouts/default.vue @@ -9,7 +9,6 @@ import { ROUTES } from '~/constants/routes' const route = useRoute() const { t } = useI18n() const isScrolled = ref(false) -const isDevLoading = ref(false) // Determine if the navbar should be transparent (homepage only for now) const isHome = computed(() => route.name === 'index' || route.path === '/') diff --git a/end_user/plugins/01.modular-rest.ts b/end_user/plugins/01.modular-rest.ts index ff8e388..9af9141 100644 --- a/end_user/plugins/01.modular-rest.ts +++ b/end_user/plugins/01.modular-rest.ts @@ -1,10 +1,58 @@ import { GlobalOptions, authentication, dataProvider, fileProvider } from '@modular-rest/client' +import { useDataCache } from '~/composables/useDataCache' /** * Initialize the Modular REST Client with global options * This plugin runs on both server and client side */ export default defineNuxtPlugin((nuxtApp) => { + const { generateKey, getCachedData, setCachedData, syncSsrToSessionStorage } = useDataCache() + devLog('ModularRest', 'Initializing with Cache support'); + + /** + * Enhanced DataProvider with session-based caching. + * We monkey-patch the original dataProvider methods to ensure that + * even direct imports of dataProvider from @modular-rest/client + * benefit from the caching system. + */ + const originalFind = dataProvider.find.bind(dataProvider) + const originalFindOne = dataProvider.findOne.bind(dataProvider) + + dataProvider.find = async (params: any): Promise => { + const key = generateKey({ type: 'find', ...params }) + const cached = getCachedData(key) + if (cached) { + devLog('DataProvider', 'Serving "find" from cache'); + return cached + } + + devLog('DataProvider', '"find" cache miss, fetching from network...'); + const result = await originalFind(params) + setCachedData(key, result) + return result + } + + dataProvider.findOne = (async (params: any): Promise => { + const key = generateKey({ type: 'findOne', ...params }) + const cached = getCachedData(key) + if (cached) { + devLog('DataProvider', 'Serving "findOne" from cache'); + return cached + } + + devLog('DataProvider', '"findOne" cache miss, fetching from network...'); + const result = await originalFindOne(params) + setCachedData(key, result) + return result as T + }) as any + + // Sync SSR cache to sessionStorage when the app mounts on the client + if (import.meta.client) { + nuxtApp.hook('app:mounted', () => { + syncSsrToSessionStorage() + }) + } + try { const config = useRuntimeConfig() let baseUrl = config.public.apiBaseUrl @@ -15,7 +63,7 @@ export default defineNuxtPlugin((nuxtApp) => { } // Normalize baseUrl - ensure it's a proper absolute path or full URL - if (process.client) { + if (import.meta.client) { // Client-side: ALWAYS use full URL with origin to prevent relative path issues // This prevents issues when on routes like /tab/123 where relative URLs // would resolve to /tab/api/... instead of /api/... @@ -43,7 +91,7 @@ export default defineNuxtPlugin((nuxtApp) => { GlobalOptions.set({ host: baseUrl }) // Log configuration (only on client to avoid SSR spam) - if (process.client) { + if (import.meta.client) { console.log('[ModularRest] GlobalOptions host configured:', baseUrl) // Double-check it's a full URL on client if (!baseUrl.startsWith('http')) { @@ -60,11 +108,9 @@ export default defineNuxtPlugin((nuxtApp) => { provide: { modularRest: { authentication, - dataProvider, + dataProvider, // Now monkey-patched fileProvider, }, }, } }) - - diff --git a/end_user/utils/logger.ts b/end_user/utils/logger.ts new file mode 100644 index 0000000..9de029b --- /dev/null +++ b/end_user/utils/logger.ts @@ -0,0 +1,20 @@ +/** + * General logger utility that only logs in development mode. + * + * @param tag - A tag to identify the log source (e.g., 'Cache', 'Auth') + * @param message - The message to log + * @param data - Optional data to log along with the message + */ +export const devLog = (tag: string, message: string, data?: any) => { + if (import.meta.dev) { + const time = new Date().toLocaleTimeString(); + const prefix = `[${time}] [${tag}]`; + + if (data !== undefined) { + console.log(`${prefix} ${message}`, data); + } else { + console.log(`${prefix} ${message}`); + } + } +}; +