Skip to content

Commit e8e9017

Browse files
authored
Merge pull request #5 from thinkgrid-labs/dev
feat(core): implement L1 cache, deduplication, and reactive architecture
2 parents d60d792 + 9e1f881 commit e8e9017

9 files changed

Lines changed: 274 additions & 44 deletions

File tree

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ await removeFromStorage('user-routes');
101101
await clearAllStorage();
102102
```
103103

104+
## 🔐 Security Best Practices
105+
106+
When using `encrypt: true`, you **MUST NOT** hardcode the `secret` in your frontend source code! Doing so renders the encryption completely useless, as anyone can inspect your client bundle and find the key.
107+
108+
Instead, the `secret` should either be:
109+
1. Derived from user input (e.g., a PIN code or password they enter).
110+
2. Retrieved dynamically from your backend for the active session and stored only in memory.
111+
104112
## ⚙️ Configuration Options
105113

106114
| Option | Type | Default | Description |

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@thinkgrid/react-local-fetch",
3-
"version": "0.2.0",
3+
"version": "0.3.0",
44
"description": "Resilient, encrypted, local-first fetching for React and Next.js using IndexedDB.",
55
"main": "dist/index.js",
66
"module": "dist/index.mjs",

src/events.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* Default internal event emitter for local-fetch cache updates.
3+
* Used to broadcast revalidation updates to mounted React hooks.
4+
*/
5+
6+
type ListenerCallback = (key: string) => void;
7+
8+
class LocalFetchEventEmitter {
9+
private listeners = new Map<string, Set<ListenerCallback>>();
10+
11+
subscribe(key: string, callback: ListenerCallback): () => void {
12+
if (!this.listeners.has(key)) {
13+
this.listeners.set(key, new Set());
14+
}
15+
this.listeners.get(key)!.add(callback);
16+
17+
return () => {
18+
const callbacks = this.listeners.get(key);
19+
if (callbacks) {
20+
callbacks.delete(callback);
21+
if (callbacks.size === 0) {
22+
this.listeners.delete(key);
23+
}
24+
}
25+
};
26+
}
27+
28+
emit(key: string) {
29+
const callbacks = this.listeners.get(key);
30+
if (callbacks) {
31+
callbacks.forEach((cb) => cb(key));
32+
}
33+
}
34+
}
35+
36+
export const cacheEmitter = new LocalFetchEventEmitter();

src/fetcher.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,20 +26,22 @@ describe('localFetch fetcher', () => {
2626
});
2727

2828
it('should fetch from network if cache is empty', async () => {
29+
const uniqueOptions = { ...options, key: 'test-key-1' };
2930
const mockData = { id: 1, name: 'Test' };
3031
(storage.getFromStorage as any).mockResolvedValue(undefined);
3132
(fetch as any).mockResolvedValue({
3233
ok: true,
3334
json: () => Promise.resolve(mockData),
3435
});
3536

36-
const result = await localFetch(url, options);
37+
const result = await localFetch(url, uniqueOptions);
3738

3839
expect(result).toEqual(mockData);
3940
expect(storage.saveToStorage).toHaveBeenCalled();
4041
});
4142

4243
it('should return from cache if valid and not expired', async () => {
44+
const uniqueOptions = { ...options, key: 'test-key-2' };
4345
const mockData = { id: 1, name: 'Cached' };
4446
(storage.getFromStorage as any).mockResolvedValue({
4547
data: mockData,
@@ -50,13 +52,14 @@ describe('localFetch fetcher', () => {
5052
},
5153
});
5254

53-
const result = await localFetch(url, options);
55+
const result = await localFetch(url, uniqueOptions);
5456

5557
expect(result).toEqual(mockData);
5658
expect(fetch).toHaveBeenCalledTimes(1); // Background revalidation
5759
});
5860

5961
it('should clear cache if version is mismatched', async () => {
62+
const uniqueOptions = { ...options, key: 'test-key-3' };
6063
const mockData = { id: 2, name: 'New' };
6164
(storage.getFromStorage as any).mockResolvedValue({
6265
data: { id: 1, name: 'Old' },
@@ -71,7 +74,7 @@ describe('localFetch fetcher', () => {
7174
json: () => Promise.resolve(mockData),
7275
});
7376

74-
const result = await localFetch(url, options);
77+
const result = await localFetch(url, uniqueOptions);
7578

7679
expect(result).toEqual(mockData);
7780
expect(storage.saveToStorage).toHaveBeenCalled();

src/fetcher.ts

Lines changed: 115 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { LocalFetchOptions, CacheEntry, CacheMetadata } from './types';
2-
import { getFromStorage, saveToStorage } from './storage';
2+
import { getFromStorage, saveToStorage, removeFromStorage } from './storage';
33
import { encrypt, decrypt } from './crypto';
4+
import { cacheEmitter } from './events';
45

56
const isServer = typeof window === 'undefined';
7+
const memoryCache = new Map<string, CacheEntry<any>>();
68

79
/**
810
* The core fetching engine for react-local-fetch.
@@ -27,8 +29,15 @@ export async function localFetch<T>(
2729
return await response.json();
2830
}
2931

30-
// 1. Try to get from cache
31-
const cached = await getFromStorage<T>(key);
32+
// 1. Try to get from cache (L1 then L2)
33+
let cached = memoryCache.get(key) as CacheEntry<T> | undefined;
34+
35+
if (!cached) {
36+
cached = await getFromStorage<T>(key);
37+
if (cached) {
38+
memoryCache.set(key, cached);
39+
}
40+
}
3241

3342
if (cached) {
3443
const { metadata, data: storedData } = cached;
@@ -46,7 +55,7 @@ export async function localFetch<T>(
4655
if (metadata.isEncrypted) {
4756
if (!secret) throw new Error('Secret is required to decrypt data.');
4857
finalData = await decrypt(
49-
storedData,
58+
storedData as ArrayBuffer,
5059
secret,
5160
metadata.salt!,
5261
metadata.iv!
@@ -67,11 +76,11 @@ export async function localFetch<T>(
6776
// Stale data but fallback allowed
6877
revalidateBackground(url, options);
6978

70-
try {
79+
try {
7180
let finalData: string;
7281
if (metadata.isEncrypted) {
7382
if (!secret) throw new Error('Secret is required to decrypt data.');
74-
finalData = await decrypt(storedData, secret, metadata.salt!, metadata.iv!);
83+
finalData = await decrypt(storedData as ArrayBuffer, secret, metadata.salt!, metadata.iv!);
7584
} else {
7685
finalData = typeof storedData === 'string' ? storedData : JSON.stringify(storedData);
7786
}
@@ -86,45 +95,71 @@ export async function localFetch<T>(
8695
return await fetchAndStore(url, options);
8796
}
8897

98+
const activeRequests = new Map<string, Promise<any>>();
99+
89100
/**
90101
* Fetches data from network, encrypts it (if needed), and stores it.
91102
*/
92103
async function fetchAndStore<T>(url: string, options: LocalFetchOptions): Promise<T> {
93-
const { key, version = 0, encrypt: shouldEncrypt = false, secret, headers = {} } = options;
104+
const { key, version = 0, encrypt: shouldEncrypt = false, secret, headers = {}, updateStrategy = 'reactive' } = options;
94105

95-
const response = await fetch(url, { headers });
96-
if (!response.ok) {
97-
throw new Error(`HTTP error! status: ${response.status}`);
98-
}
99-
const freshData = await response.json();
100-
const jsonString = JSON.stringify(freshData);
101-
102-
const metadata: CacheMetadata = {
103-
timestamp: Date.now(),
104-
version,
105-
isEncrypted: shouldEncrypt,
106-
};
107-
108-
let dataToStore: any;
109-
110-
if (shouldEncrypt) {
111-
if (!secret) throw new Error('Secret is required to encrypt data.');
112-
const { buffer, salt, iv } = await encrypt(jsonString, secret);
113-
dataToStore = buffer;
114-
metadata.salt = salt;
115-
metadata.iv = iv;
116-
} else {
117-
dataToStore = freshData;
106+
if (activeRequests.has(key)) {
107+
return activeRequests.get(key) as Promise<T>;
118108
}
119109

120-
const entry: CacheEntry<T> = {
121-
data: dataToStore,
122-
metadata,
123-
};
110+
const promise = (async () => {
111+
const response = await fetch(url, { headers });
112+
if (!response.ok) {
113+
throw new Error(`HTTP error! status: ${response.status}`);
114+
}
115+
const freshData = await response.json();
116+
const jsonString = JSON.stringify(freshData);
117+
118+
const metadata: CacheMetadata = {
119+
timestamp: Date.now(),
120+
version,
121+
isEncrypted: shouldEncrypt,
122+
};
123+
124+
let dataToStore: any;
125+
126+
if (shouldEncrypt) {
127+
if (!secret) throw new Error('Secret is required to encrypt data.');
128+
const { buffer, salt, iv } = await encrypt(jsonString, secret);
129+
dataToStore = buffer;
130+
metadata.salt = salt;
131+
metadata.iv = iv;
132+
} else {
133+
dataToStore = freshData;
134+
}
135+
136+
const entry: CacheEntry<T> = {
137+
data: dataToStore,
138+
metadata,
139+
};
140+
141+
memoryCache.set(key, entry);
142+
143+
try {
144+
await saveToStorage(key, entry);
145+
} catch (err) {
146+
console.warn('Failed to save to local storage', err);
147+
}
148+
149+
if (updateStrategy !== 'silent') {
150+
cacheEmitter.emit(key);
151+
}
152+
153+
return freshData;
154+
})();
124155

125-
await saveToStorage(key, entry);
156+
activeRequests.set(key, promise);
126157

127-
return freshData;
158+
try {
159+
return await promise;
160+
} finally {
161+
activeRequests.delete(key);
162+
}
128163
}
129164

130165
/**
@@ -137,3 +172,47 @@ async function revalidateBackground(url: string, options: LocalFetchOptions): Pr
137172
console.warn(`Background sync failed for ${url}`, err);
138173
}
139174
}
175+
176+
/**
177+
* Mutates the cache for a given key, triggering revalidation in active hooks.
178+
*/
179+
export async function mutate<T>(
180+
key: string,
181+
data?: T,
182+
options?: Partial<LocalFetchOptions>
183+
): Promise<void> {
184+
if (data !== undefined) {
185+
const jsonString = JSON.stringify(data);
186+
let dataToStore: any = data;
187+
const metadata: CacheMetadata = {
188+
timestamp: Date.now(),
189+
version: options?.version || 0,
190+
isEncrypted: !!options?.encrypt,
191+
};
192+
193+
if (options?.encrypt && options?.secret) {
194+
const { buffer, salt, iv } = await encrypt(jsonString, options.secret);
195+
dataToStore = buffer;
196+
metadata.salt = salt;
197+
metadata.iv = iv;
198+
}
199+
200+
const entry: CacheEntry<T> = { data: dataToStore, metadata };
201+
202+
memoryCache.set(key, entry);
203+
try {
204+
await saveToStorage(key, entry);
205+
} catch (err) {
206+
console.warn('mutate failed to save to storage', err);
207+
}
208+
} else {
209+
memoryCache.delete(key);
210+
try {
211+
await removeFromStorage(key);
212+
} catch (err) {
213+
console.warn('mutate failed to remove from storage', err);
214+
}
215+
}
216+
217+
cacheEmitter.emit(key);
218+
}

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from './types';
2-
export { localFetch } from './fetcher';
2+
export { localFetch, mutate } from './fetcher';
33
export { useLocalFetch } from './useLocalFetch';
44
export { clearAllStorage, removeFromStorage } from './storage';
5+
export { LocalFetchProvider, useLocalFetchContext } from './provider';
6+
export type { LocalFetchProviderProps } from './provider';

src/provider.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { createContext, useContext, useEffect } from 'react';
2+
import { LocalFetchOptions } from './types';
3+
import { cacheEmitter } from './events';
4+
import { localFetch } from './fetcher';
5+
6+
const LocalFetchContext = createContext<Partial<LocalFetchOptions>>({});
7+
8+
export function useLocalFetchContext() {
9+
return useContext(LocalFetchContext);
10+
}
11+
12+
export interface LocalFetchProviderProps {
13+
children: React.ReactNode;
14+
defaultOptions?: Partial<LocalFetchOptions>;
15+
}
16+
17+
export function LocalFetchProvider({ children, defaultOptions = {} }: LocalFetchProviderProps) {
18+
// Global focus and reconnect listeners
19+
useEffect(() => {
20+
if (!defaultOptions.revalidateOnFocus && !defaultOptions.revalidateOnReconnect) {
21+
return;
22+
}
23+
24+
const onFocus = () => {
25+
if (defaultOptions.revalidateOnFocus) {
26+
// Broadcast a global revalidation event for all active hooks to pick up.
27+
// Or we could trigger `revalidate` on all active options.
28+
// It's cleaner to emit a special event that useLocalFetch listens to.
29+
cacheEmitter.emit('__global_focus');
30+
}
31+
};
32+
33+
const onOnline = () => {
34+
if (defaultOptions.revalidateOnReconnect) {
35+
cacheEmitter.emit('__global_reconnect');
36+
}
37+
};
38+
39+
if (defaultOptions.revalidateOnFocus) {
40+
window.addEventListener('focus', onFocus);
41+
window.addEventListener('visibilitychange', () => {
42+
if (document.visibilityState === 'visible') onFocus();
43+
});
44+
}
45+
46+
if (defaultOptions.revalidateOnReconnect) {
47+
window.addEventListener('online', onOnline);
48+
}
49+
50+
return () => {
51+
window.removeEventListener('focus', onFocus);
52+
window.removeEventListener('visibilitychange', onFocus);
53+
window.removeEventListener('online', onOnline);
54+
};
55+
}, [defaultOptions.revalidateOnFocus, defaultOptions.revalidateOnReconnect]);
56+
57+
return (
58+
<LocalFetchContext.Provider value={defaultOptions}>
59+
{children}
60+
</LocalFetchContext.Provider>
61+
);
62+
}

0 commit comments

Comments
 (0)