From 98cbf14577967943341b006887b9488a18e0bd42 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 18 Dec 2025 16:16:58 +0200 Subject: [PATCH 1/3] Merge branch 'main' into dev --- docker-compose.local.yaml | 3 ++- docker-compose.yaml | 2 ++ end_user/nuxt.config.ts | 2 ++ nginx/default.conf | 6 ++++++ nginx/default.local.conf | 6 ++++++ 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docker-compose.local.yaml b/docker-compose.local.yaml index 76b318a..c3e4f26 100644 --- a/docker-compose.local.yaml +++ b/docker-compose.local.yaml @@ -65,7 +65,8 @@ services: environment: - NODE_ENV=${NODE_ENV:-development} - NUXT_API_BASE_URL=/api/ - - NUXT_SSR_API_BASE_URL=http://localhost/api/ + - NUXT_PUBLIC_BASE_URL=http://localhost + - NUXT_SSR_API_BASE_URL=http://localhost/api - PORT=8080 - NITRO_PORT=8080 depends_on: diff --git a/docker-compose.yaml b/docker-compose.yaml index 1e10360..413bcda 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -49,6 +49,8 @@ services: environment: - NODE_ENV=production - NUXT_API_BASE_URL=/api/ + - NUXT_PUBLIC_BASE_URL=https://goranee.ir + - NUXT_SSR_API_BASE_URL=https://goranee.ir/api depends_on: - server networks: diff --git a/end_user/nuxt.config.ts b/end_user/nuxt.config.ts index 013a53e..89ed0b9 100644 --- a/end_user/nuxt.config.ts +++ b/end_user/nuxt.config.ts @@ -24,6 +24,8 @@ export default defineNuxtConfig({ apiBaseUrl: process.env.NUXT_API_BASE_URL || process.env.VITE_API_BASE_URL || '/api/', // @ts-ignore baseUrl: process.env.NUXT_PUBLIC_BASE_URL || process.env.BASE_URL || 'https://goranee.ir', + // @ts-ignore + ssrApiBaseUrl: process.env.NUXT_SSR_API_BASE_URL || process.env.VITE_SSR_API_BASE_URL || 'https://goranee.ir/api', }, }, diff --git a/nginx/default.conf b/nginx/default.conf index 5110b80..75271e4 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -96,6 +96,12 @@ server { location / { proxy_pass http://end_user:8080; proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_cache_bypass $http_upgrade; diff --git a/nginx/default.local.conf b/nginx/default.local.conf index 1ac7958..66a715c 100644 --- a/nginx/default.local.conf +++ b/nginx/default.local.conf @@ -73,6 +73,12 @@ server { location / { proxy_pass http://end_user:8080; proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_cache_bypass $http_upgrade; From 8003ac0646fd658301d5f2da4c116efb03f5f1f4 Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 18 Dec 2025 17:13:04 +0200 Subject: [PATCH 2/3] feat: #86evxd43z Enhance Modular REST Client with caching support - Introduced session-based caching for dataProvider methods to improve performance and reduce network requests. - Updated the plugin to sync SSR cache to sessionStorage on client mount. - Refactored client-side checks to use import.meta.client for better compatibility. --- end_user/composables/useDataCache.ts | 122 +++++++++++++++++++++++++++ end_user/layouts/default.vue | 1 - end_user/plugins/01.modular-rest.ts | 56 ++++++++++-- end_user/utils/logger.ts | 20 +++++ 4 files changed, 193 insertions(+), 6 deletions(-) create mode 100644 end_user/composables/useDataCache.ts create mode 100644 end_user/utils/logger.ts diff --git a/end_user/composables/useDataCache.ts b/end_user/composables/useDataCache.ts new file mode 100644 index 0000000..d877865 --- /dev/null +++ b/end_user/composables/useDataCache.ts @@ -0,0 +1,122 @@ +/** + * 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. + */ +export const useDataCache = () => { + /** + * The SSR bridge state. Nuxt automatically serializes this into the page payload. + * Format: { [cacheKey: string]: any } + */ + 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); + }; + + /** + * Retrieves data from the multi-layer cache. + * Layer 1: Nuxt useState (priority for hydration) + * Layer 2: Browser sessionStorage (priority for client-side navigation) + * + * @param key - The cache key generated by generateKey + * @returns The cached data or null if not found + */ + const getCachedData = (key: string): any | null => { + // 1. Check the SSR bridge (fastest, especially during hydration) + if (ssrBridge.value[key]) { + devLog('Cache', `Hit (SSR Bridge): ${key.substring(0, 40)}...`); + return ssrBridge.value[key]; + } + + // 2. Check sessionStorage if on client side + if (import.meta.client) { + try { + const stored = sessionStorage.getItem(`mr-cache:${key}`); + if (stored) { + devLog('Cache', `Hit (SessionStorage): ${key.substring(0, 40)}...`); + const parsed = JSON.parse(stored); + // Optional: You could add a timestamp/TTL check here + return parsed; + } + } catch (e) { + console.warn('[useDataCache] Failed to read from sessionStorage', e); + } + } + + return null; + }; + + /** + * Stores data in the multi-layer cache. + * + * @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)}...`); + // Store in SSR bridge + ssrBridge.value[key] = data; + + // Store in sessionStorage if on client side + if (import.meta.client) { + try { + sessionStorage.setItem(`mr-cache:${key}`, JSON.stringify(data)); + } catch (e) { + // Handle QuotaExceededError or other storage issues + console.warn('[useDataCache] Failed to write to sessionStorage', e); + } + } + }; + + /** + * Flushes all data from the SSR bridge into sessionStorage. + * Usually called once when the app is mounted on the client. + */ + const syncSsrToSessionStorage = (): void => { + if (!import.meta.client) return; + + const entries = Object.entries(ssrBridge.value); + if (entries.length > 0) { + devLog('Cache', `Syncing ${entries.length} items from SSR to SessionStorage`); + } + + entries.forEach(([key, data]) => { + try { + const storageKey = `mr-cache:${key}`; + if (!sessionStorage.getItem(storageKey)) { + sessionStorage.setItem(storageKey, JSON.stringify(data)); + } + } catch (e) { + console.warn('[useDataCache] Sync failed for key:', key, e); + } + }); + }; + + 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}`); + } + } +}; + From 08239c96314d4b826547b83c8a81ecd8e0d7ac2f Mon Sep 17 00:00:00 2001 From: Navid Shad Date: Thu, 18 Dec 2025 17:45:22 +0200 Subject: [PATCH 3/3] feat: #86evxd43z Enhance useDataCache composable with expiration handling and improved data structure - Introduced cache expiration logic to ensure stale data is removed after 30 minutes. - Updated cache entry format to include timestamps for better validity checks. - Refactored data retrieval methods to prioritize sessionStorage and handle legacy formats. - Added cleanup functionality to remove expired entries from sessionStorage. - Improved synchronization of valid SSR bridge data to sessionStorage for enhanced performance. --- end_user/composables/useDataCache.ts | 164 ++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 31 deletions(-) diff --git a/end_user/composables/useDataCache.ts b/end_user/composables/useDataCache.ts index d877865..15e4135 100644 --- a/end_user/composables/useDataCache.ts +++ b/end_user/composables/useDataCache.ts @@ -1,14 +1,25 @@ +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]: any } + * 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', () => ({})); + const ssrBridge = useState>('ssr-data-cache', () => ({})); /** * Generates a stable unique cache key from request parameters. @@ -33,54 +44,87 @@ export const useDataCache = () => { 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: Nuxt useState (priority for hydration) - * Layer 2: Browser sessionStorage (priority for client-side navigation) + * 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 + * @returns The cached data or null if not found or expired */ const getCachedData = (key: string): any | null => { - // 1. Check the SSR bridge (fastest, especially during hydration) - if (ssrBridge.value[key]) { - devLog('Cache', `Hit (SSR Bridge): ${key.substring(0, 40)}...`); - return ssrBridge.value[key]; - } - - // 2. Check sessionStorage if on client side - if (import.meta.client) { + // 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) { - devLog('Cache', `Hit (SessionStorage): ${key.substring(0, 40)}...`); const parsed = JSON.parse(stored); - // Optional: You could add a timestamp/TTL check here - return parsed; + // 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. + * 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)}...`); - // Store in SSR bridge - ssrBridge.value[key] = data; + 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(data)); + 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); @@ -89,27 +133,85 @@ export const useDataCache = () => { }; /** - * Flushes all data from the SSR bridge into sessionStorage. + * 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); - if (entries.length > 0) { - devLog('Cache', `Syncing ${entries.length} items from SSR to SessionStorage`); - } + let syncedCount = 0; - entries.forEach(([key, data]) => { - try { - const storageKey = `mr-cache:${key}`; - if (!sessionStorage.getItem(storageKey)) { - sessionStorage.setItem(storageKey, JSON.stringify(data)); + 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); } - } 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 {