Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 224 additions & 0 deletions end_user/composables/useDataCache.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, { data: any; timestamp: number }>>('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,
};
};

1 change: 0 additions & 1 deletion end_user/layouts/default.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 === '/')
Expand Down
56 changes: 51 additions & 5 deletions end_user/plugins/01.modular-rest.ts
Original file line number Diff line number Diff line change
@@ -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 <T>(params: any): Promise<T[]> => {
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<T>(params)
setCachedData(key, result)
return result
}

dataProvider.findOne = (async <T>(params: any): Promise<T> => {
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<T>(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
Expand All @@ -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/...
Expand Down Expand Up @@ -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')) {
Expand All @@ -60,11 +108,9 @@ export default defineNuxtPlugin((nuxtApp) => {
provide: {
modularRest: {
authentication,
dataProvider,
dataProvider, // Now monkey-patched
fileProvider,
},
},
}
})


20 changes: 20 additions & 0 deletions end_user/utils/logger.ts
Original file line number Diff line number Diff line change
@@ -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}`);
}
}
};