Skip to content

Commit 56b6146

Browse files
authored
Merge pull request #3 from codebridger/dev
Dev
2 parents 6462852 + dd81323 commit 56b6146

4 files changed

Lines changed: 295 additions & 6 deletions

File tree

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { devLog } from '~/utils/logger'
2+
3+
/**
4+
* Composable for managing a session-based data cache that bridges SSR and Client-side.
5+
* This cache uses Nuxt's `useState` to transfer data from server to client during hydration,
6+
* and `sessionStorage` to persist that data across page navigations in the same browser session.
7+
*
8+
* Cache entries expire after 30 minutes to prevent stale data.
9+
*/
10+
export const useDataCache = () => {
11+
/**
12+
* Cache TTL in milliseconds (30 minutes)
13+
*/
14+
const CACHE_TTL = 30 * 60 * 1000; // 30 minutes
15+
16+
/**
17+
* The SSR bridge state. Nuxt automatically serializes this into the page payload.
18+
* Format: { [cacheKey: string]: { data: any, timestamp: number } }
19+
* Note: This persists during the session for client-side navigation. It's synced to
20+
* sessionStorage on mount but kept in memory for fast access during navigation.
21+
*/
22+
const ssrBridge = useState<Record<string, { data: any; timestamp: number }>>('ssr-data-cache', () => ({}));
23+
24+
/**
25+
* Generates a stable unique cache key from request parameters.
26+
* Ensures that object property order doesn't affect the resulting key.
27+
*
28+
* @param params - The parameters used for the dataProvider request
29+
* @returns A stable stringified hash-like key
30+
*/
31+
const generateKey = (params: any): string => {
32+
// Stable stringify to handle object property order
33+
const stableStringify = (obj: any): string => {
34+
if (obj === null || typeof obj !== 'object') {
35+
return String(obj);
36+
}
37+
if (Array.isArray(obj)) {
38+
return '[' + obj.map(stableStringify).join(',') + ']';
39+
}
40+
const keys = Object.keys(obj).sort();
41+
return '{' + keys.map(k => `${k}:${stableStringify(obj[k])}`).join(',') + '}';
42+
};
43+
44+
return stableStringify(params);
45+
};
46+
47+
/**
48+
* Checks if a cache entry is still valid (not expired).
49+
*
50+
* @param timestamp - The timestamp when the entry was cached
51+
* @returns true if the entry is still valid, false if expired
52+
*/
53+
const isCacheValid = (timestamp: number): boolean => {
54+
const now = Date.now();
55+
return (now - timestamp) < CACHE_TTL;
56+
};
57+
58+
/**
59+
* Retrieves data from the multi-layer cache.
60+
* Layer 1: Browser sessionStorage (available on both SSR and client)
61+
* Layer 2: Nuxt useState SSR bridge (for hydration and client-side navigation)
62+
*
63+
* @param key - The cache key generated by generateKey
64+
* @returns The cached data or null if not found or expired
65+
*/
66+
const getCachedData = (key: string): any | null => {
67+
// 1. Check sessionStorage first (works on both SSR hydration and client-side navigation)
68+
// Note: sessionStorage is only available on client, but we check it first when available
69+
if (import.meta.client && typeof sessionStorage !== 'undefined') {
70+
try {
71+
const stored = sessionStorage.getItem(`mr-cache:${key}`);
72+
if (stored) {
73+
const parsed = JSON.parse(stored);
74+
// Check if it's the new format with timestamp
75+
if (parsed && typeof parsed === 'object' && 'timestamp' in parsed && 'data' in parsed) {
76+
if (isCacheValid(parsed.timestamp)) {
77+
devLog('Cache', `Hit (SessionStorage): ${key.substring(0, 40)}...`);
78+
// Also update SSR bridge for faster subsequent access
79+
ssrBridge.value[key] = parsed;
80+
return parsed.data;
81+
} else {
82+
// Expired - remove it
83+
sessionStorage.removeItem(`mr-cache:${key}`);
84+
devLog('Cache', `Expired entry removed: ${key.substring(0, 40)}...`);
85+
}
86+
} else {
87+
// Legacy format (no timestamp) - treat as expired
88+
sessionStorage.removeItem(`mr-cache:${key}`);
89+
devLog('Cache', `Legacy entry removed: ${key.substring(0, 40)}...`);
90+
}
91+
}
92+
} catch (e) {
93+
console.warn('[useDataCache] Failed to read from sessionStorage', e);
94+
}
95+
}
96+
97+
// 2. Check the SSR bridge (for SSR hydration and client-side navigation)
98+
const ssrEntry = ssrBridge.value[key];
99+
if (ssrEntry && isCacheValid(ssrEntry.timestamp)) {
100+
devLog('Cache', `Hit (SSR Bridge): ${key.substring(0, 40)}...`);
101+
return ssrEntry.data;
102+
} else if (ssrEntry) {
103+
// Expired entry in SSR bridge - remove it
104+
delete ssrBridge.value[key];
105+
}
106+
107+
return null;
108+
};
109+
110+
/**
111+
* Stores data in the multi-layer cache with timestamp.
112+
*
113+
* @param key - The cache key generated by generateKey
114+
* @param data - The data to cache
115+
*/
116+
const setCachedData = (key: string, data: any): void => {
117+
devLog('Cache', `Storing: ${key.substring(0, 40)}...`);
118+
const timestamp = Date.now();
119+
const cacheEntry = { data, timestamp };
120+
121+
// Store in SSR bridge (only for current request hydration)
122+
ssrBridge.value[key] = cacheEntry;
123+
124+
// Store in sessionStorage if on client side
125+
if (import.meta.client) {
126+
try {
127+
sessionStorage.setItem(`mr-cache:${key}`, JSON.stringify(cacheEntry));
128+
} catch (e) {
129+
// Handle QuotaExceededError or other storage issues
130+
console.warn('[useDataCache] Failed to write to sessionStorage', e);
131+
}
132+
}
133+
};
134+
135+
/**
136+
* Cleans up expired entries from sessionStorage.
137+
*/
138+
const cleanupExpiredEntries = (): void => {
139+
if (!import.meta.client) return;
140+
141+
try {
142+
const keysToRemove: string[] = [];
143+
for (let i = 0; i < sessionStorage.length; i++) {
144+
const key = sessionStorage.key(i);
145+
if (key && key.startsWith('mr-cache:')) {
146+
try {
147+
const stored = sessionStorage.getItem(key);
148+
if (stored) {
149+
const parsed = JSON.parse(stored);
150+
if (parsed && typeof parsed === 'object' && 'timestamp' in parsed) {
151+
if (!isCacheValid(parsed.timestamp)) {
152+
keysToRemove.push(key);
153+
}
154+
} else {
155+
// Legacy format - remove it
156+
keysToRemove.push(key);
157+
}
158+
}
159+
} catch (e) {
160+
// Invalid entry - remove it
161+
keysToRemove.push(key);
162+
}
163+
}
164+
}
165+
keysToRemove.forEach(key => sessionStorage.removeItem(key));
166+
if (keysToRemove.length > 0) {
167+
devLog('Cache', `Cleaned up ${keysToRemove.length} expired entries`);
168+
}
169+
} catch (e) {
170+
console.warn('[useDataCache] Cleanup failed', e);
171+
}
172+
};
173+
174+
/**
175+
* Flushes valid data from the SSR bridge into sessionStorage.
176+
* Keeps SSR bridge available for client-side navigation (it persists during session).
177+
* Usually called once when the app is mounted on the client.
178+
*/
179+
const syncSsrToSessionStorage = (): void => {
180+
if (!import.meta.client) return;
181+
182+
// Clean up expired entries first
183+
cleanupExpiredEntries();
184+
185+
const entries = Object.entries(ssrBridge.value);
186+
let syncedCount = 0;
187+
188+
entries.forEach(([key, entry]) => {
189+
// Only sync valid (non-expired) entries
190+
if (isCacheValid(entry.timestamp)) {
191+
try {
192+
const storageKey = `mr-cache:${key}`;
193+
// Only sync if not already present (avoid overwriting newer data)
194+
const existing = sessionStorage.getItem(storageKey);
195+
if (!existing) {
196+
sessionStorage.setItem(storageKey, JSON.stringify(entry));
197+
syncedCount++;
198+
}
199+
} catch (e) {
200+
console.warn('[useDataCache] Sync failed for key:', key, e);
201+
}
202+
} else {
203+
// Remove expired entries from SSR bridge
204+
delete ssrBridge.value[key];
205+
}
206+
});
207+
208+
if (syncedCount > 0) {
209+
devLog('Cache', `Synced ${syncedCount} items from SSR to SessionStorage`);
210+
}
211+
212+
// Note: We keep SSR bridge available for client-side navigation
213+
// It will naturally clear when the page reloads or tab closes
214+
// This allows instant cache hits during client-side navigation
215+
};
216+
217+
return {
218+
generateKey,
219+
getCachedData,
220+
setCachedData,
221+
syncSsrToSessionStorage,
222+
};
223+
};
224+

end_user/layouts/default.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { ROUTES } from '~/constants/routes'
99
const route = useRoute()
1010
const { t } = useI18n()
1111
const isScrolled = ref(false)
12-
const isDevLoading = ref(false)
1312
1413
// Determine if the navbar should be transparent (homepage only for now)
1514
const isHome = computed(() => route.name === 'index' || route.path === '/')

end_user/plugins/01.modular-rest.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,58 @@
11
import { GlobalOptions, authentication, dataProvider, fileProvider } from '@modular-rest/client'
2+
import { useDataCache } from '~/composables/useDataCache'
23

34
/**
45
* Initialize the Modular REST Client with global options
56
* This plugin runs on both server and client side
67
*/
78
export default defineNuxtPlugin((nuxtApp) => {
9+
const { generateKey, getCachedData, setCachedData, syncSsrToSessionStorage } = useDataCache()
10+
devLog('ModularRest', 'Initializing with Cache support');
11+
12+
/**
13+
* Enhanced DataProvider with session-based caching.
14+
* We monkey-patch the original dataProvider methods to ensure that
15+
* even direct imports of dataProvider from @modular-rest/client
16+
* benefit from the caching system.
17+
*/
18+
const originalFind = dataProvider.find.bind(dataProvider)
19+
const originalFindOne = dataProvider.findOne.bind(dataProvider)
20+
21+
dataProvider.find = async <T>(params: any): Promise<T[]> => {
22+
const key = generateKey({ type: 'find', ...params })
23+
const cached = getCachedData(key)
24+
if (cached) {
25+
devLog('DataProvider', 'Serving "find" from cache');
26+
return cached
27+
}
28+
29+
devLog('DataProvider', '"find" cache miss, fetching from network...');
30+
const result = await originalFind<T>(params)
31+
setCachedData(key, result)
32+
return result
33+
}
34+
35+
dataProvider.findOne = (async <T>(params: any): Promise<T> => {
36+
const key = generateKey({ type: 'findOne', ...params })
37+
const cached = getCachedData(key)
38+
if (cached) {
39+
devLog('DataProvider', 'Serving "findOne" from cache');
40+
return cached
41+
}
42+
43+
devLog('DataProvider', '"findOne" cache miss, fetching from network...');
44+
const result = await originalFindOne<T>(params)
45+
setCachedData(key, result)
46+
return result as T
47+
}) as any
48+
49+
// Sync SSR cache to sessionStorage when the app mounts on the client
50+
if (import.meta.client) {
51+
nuxtApp.hook('app:mounted', () => {
52+
syncSsrToSessionStorage()
53+
})
54+
}
55+
856
try {
957
const config = useRuntimeConfig()
1058
let baseUrl = config.public.apiBaseUrl
@@ -15,7 +63,7 @@ export default defineNuxtPlugin((nuxtApp) => {
1563
}
1664

1765
// Normalize baseUrl - ensure it's a proper absolute path or full URL
18-
if (process.client) {
66+
if (import.meta.client) {
1967
// Client-side: ALWAYS use full URL with origin to prevent relative path issues
2068
// This prevents issues when on routes like /tab/123 where relative URLs
2169
// would resolve to /tab/api/... instead of /api/...
@@ -43,7 +91,7 @@ export default defineNuxtPlugin((nuxtApp) => {
4391
GlobalOptions.set({ host: baseUrl })
4492

4593
// Log configuration (only on client to avoid SSR spam)
46-
if (process.client) {
94+
if (import.meta.client) {
4795
console.log('[ModularRest] GlobalOptions host configured:', baseUrl)
4896
// Double-check it's a full URL on client
4997
if (!baseUrl.startsWith('http')) {
@@ -60,11 +108,9 @@ export default defineNuxtPlugin((nuxtApp) => {
60108
provide: {
61109
modularRest: {
62110
authentication,
63-
dataProvider,
111+
dataProvider, // Now monkey-patched
64112
fileProvider,
65113
},
66114
},
67115
}
68116
})
69-
70-

end_user/utils/logger.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* General logger utility that only logs in development mode.
3+
*
4+
* @param tag - A tag to identify the log source (e.g., 'Cache', 'Auth')
5+
* @param message - The message to log
6+
* @param data - Optional data to log along with the message
7+
*/
8+
export const devLog = (tag: string, message: string, data?: any) => {
9+
if (import.meta.dev) {
10+
const time = new Date().toLocaleTimeString();
11+
const prefix = `[${time}] [${tag}]`;
12+
13+
if (data !== undefined) {
14+
console.log(`${prefix} ${message}`, data);
15+
} else {
16+
console.log(`${prefix} ${message}`);
17+
}
18+
}
19+
};
20+

0 commit comments

Comments
 (0)