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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,14 @@ await removeFromStorage('user-routes');
await clearAllStorage();
```

## 🔐 Security Best Practices

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.

Instead, the `secret` should either be:
1. Derived from user input (e.g., a PIN code or password they enter).
2. Retrieved dynamically from your backend for the active session and stored only in memory.

## ⚙️ Configuration Options

| Option | Type | Default | Description |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@thinkgrid/react-local-fetch",
"version": "0.2.0",
"version": "0.3.0",
"description": "Resilient, encrypted, local-first fetching for React and Next.js using IndexedDB.",
"main": "dist/index.js",
"module": "dist/index.mjs",
Expand Down
36 changes: 36 additions & 0 deletions src/events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Default internal event emitter for local-fetch cache updates.
* Used to broadcast revalidation updates to mounted React hooks.
*/

type ListenerCallback = (key: string) => void;

class LocalFetchEventEmitter {
private listeners = new Map<string, Set<ListenerCallback>>();

subscribe(key: string, callback: ListenerCallback): () => void {
if (!this.listeners.has(key)) {
this.listeners.set(key, new Set());
}
this.listeners.get(key)!.add(callback);

return () => {
const callbacks = this.listeners.get(key);
if (callbacks) {
callbacks.delete(callback);
if (callbacks.size === 0) {
this.listeners.delete(key);
}
}
};
}

emit(key: string) {
const callbacks = this.listeners.get(key);
if (callbacks) {
callbacks.forEach((cb) => cb(key));
}
}
}

export const cacheEmitter = new LocalFetchEventEmitter();
9 changes: 6 additions & 3 deletions src/fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,22 @@ describe('localFetch fetcher', () => {
});

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

const result = await localFetch(url, options);
const result = await localFetch(url, uniqueOptions);

expect(result).toEqual(mockData);
expect(storage.saveToStorage).toHaveBeenCalled();
});

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

const result = await localFetch(url, options);
const result = await localFetch(url, uniqueOptions);

expect(result).toEqual(mockData);
expect(fetch).toHaveBeenCalledTimes(1); // Background revalidation
});

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

const result = await localFetch(url, options);
const result = await localFetch(url, uniqueOptions);

expect(result).toEqual(mockData);
expect(storage.saveToStorage).toHaveBeenCalled();
Expand Down
151 changes: 115 additions & 36 deletions src/fetcher.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { LocalFetchOptions, CacheEntry, CacheMetadata } from './types';
import { getFromStorage, saveToStorage } from './storage';
import { getFromStorage, saveToStorage, removeFromStorage } from './storage';
import { encrypt, decrypt } from './crypto';
import { cacheEmitter } from './events';

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

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

// 1. Try to get from cache
const cached = await getFromStorage<T>(key);
// 1. Try to get from cache (L1 then L2)
let cached = memoryCache.get(key) as CacheEntry<T> | undefined;

if (!cached) {
cached = await getFromStorage<T>(key);
if (cached) {
memoryCache.set(key, cached);
}
}

if (cached) {
const { metadata, data: storedData } = cached;
Expand All @@ -46,7 +55,7 @@ export async function localFetch<T>(
if (metadata.isEncrypted) {
if (!secret) throw new Error('Secret is required to decrypt data.');
finalData = await decrypt(
storedData,
storedData as ArrayBuffer,
secret,
metadata.salt!,
metadata.iv!
Expand All @@ -67,11 +76,11 @@ export async function localFetch<T>(
// Stale data but fallback allowed
revalidateBackground(url, options);

try {
try {
let finalData: string;
if (metadata.isEncrypted) {
if (!secret) throw new Error('Secret is required to decrypt data.');
finalData = await decrypt(storedData, secret, metadata.salt!, metadata.iv!);
finalData = await decrypt(storedData as ArrayBuffer, secret, metadata.salt!, metadata.iv!);
} else {
finalData = typeof storedData === 'string' ? storedData : JSON.stringify(storedData);
}
Expand All @@ -86,45 +95,71 @@ export async function localFetch<T>(
return await fetchAndStore(url, options);
}

const activeRequests = new Map<string, Promise<any>>();

/**
* Fetches data from network, encrypts it (if needed), and stores it.
*/
async function fetchAndStore<T>(url: string, options: LocalFetchOptions): Promise<T> {
const { key, version = 0, encrypt: shouldEncrypt = false, secret, headers = {} } = options;
const { key, version = 0, encrypt: shouldEncrypt = false, secret, headers = {}, updateStrategy = 'reactive' } = options;

const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const freshData = await response.json();
const jsonString = JSON.stringify(freshData);

const metadata: CacheMetadata = {
timestamp: Date.now(),
version,
isEncrypted: shouldEncrypt,
};

let dataToStore: any;

if (shouldEncrypt) {
if (!secret) throw new Error('Secret is required to encrypt data.');
const { buffer, salt, iv } = await encrypt(jsonString, secret);
dataToStore = buffer;
metadata.salt = salt;
metadata.iv = iv;
} else {
dataToStore = freshData;
if (activeRequests.has(key)) {
return activeRequests.get(key) as Promise<T>;
}

const entry: CacheEntry<T> = {
data: dataToStore,
metadata,
};
const promise = (async () => {
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const freshData = await response.json();
const jsonString = JSON.stringify(freshData);

const metadata: CacheMetadata = {
timestamp: Date.now(),
version,
isEncrypted: shouldEncrypt,
};

let dataToStore: any;

if (shouldEncrypt) {
if (!secret) throw new Error('Secret is required to encrypt data.');
const { buffer, salt, iv } = await encrypt(jsonString, secret);
dataToStore = buffer;
metadata.salt = salt;
metadata.iv = iv;
} else {
dataToStore = freshData;
}

const entry: CacheEntry<T> = {
data: dataToStore,
metadata,
};

memoryCache.set(key, entry);

try {
await saveToStorage(key, entry);
} catch (err) {
console.warn('Failed to save to local storage', err);
}

if (updateStrategy !== 'silent') {
cacheEmitter.emit(key);
}

return freshData;
})();

await saveToStorage(key, entry);
activeRequests.set(key, promise);

return freshData;
try {
return await promise;
} finally {
activeRequests.delete(key);
}
}

/**
Expand All @@ -137,3 +172,47 @@ async function revalidateBackground(url: string, options: LocalFetchOptions): Pr
console.warn(`Background sync failed for ${url}`, err);
}
}

/**
* Mutates the cache for a given key, triggering revalidation in active hooks.
*/
export async function mutate<T>(
key: string,
data?: T,
options?: Partial<LocalFetchOptions>
): Promise<void> {
if (data !== undefined) {
const jsonString = JSON.stringify(data);
let dataToStore: any = data;
const metadata: CacheMetadata = {
timestamp: Date.now(),
version: options?.version || 0,
isEncrypted: !!options?.encrypt,
};

if (options?.encrypt && options?.secret) {
const { buffer, salt, iv } = await encrypt(jsonString, options.secret);
dataToStore = buffer;
metadata.salt = salt;
metadata.iv = iv;
}

const entry: CacheEntry<T> = { data: dataToStore, metadata };

memoryCache.set(key, entry);
try {
await saveToStorage(key, entry);
} catch (err) {
console.warn('mutate failed to save to storage', err);
}
} else {
memoryCache.delete(key);
try {
await removeFromStorage(key);
} catch (err) {
console.warn('mutate failed to remove from storage', err);
}
}

cacheEmitter.emit(key);
}
4 changes: 3 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export * from './types';
export { localFetch } from './fetcher';
export { localFetch, mutate } from './fetcher';
export { useLocalFetch } from './useLocalFetch';
export { clearAllStorage, removeFromStorage } from './storage';
export { LocalFetchProvider, useLocalFetchContext } from './provider';
export type { LocalFetchProviderProps } from './provider';
62 changes: 62 additions & 0 deletions src/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { createContext, useContext, useEffect } from 'react';
import { LocalFetchOptions } from './types';
import { cacheEmitter } from './events';
import { localFetch } from './fetcher';

const LocalFetchContext = createContext<Partial<LocalFetchOptions>>({});

export function useLocalFetchContext() {
return useContext(LocalFetchContext);
}

export interface LocalFetchProviderProps {
children: React.ReactNode;
defaultOptions?: Partial<LocalFetchOptions>;
}

export function LocalFetchProvider({ children, defaultOptions = {} }: LocalFetchProviderProps) {
// Global focus and reconnect listeners
useEffect(() => {
if (!defaultOptions.revalidateOnFocus && !defaultOptions.revalidateOnReconnect) {
return;
}

const onFocus = () => {
if (defaultOptions.revalidateOnFocus) {
// Broadcast a global revalidation event for all active hooks to pick up.
// Or we could trigger `revalidate` on all active options.
// It's cleaner to emit a special event that useLocalFetch listens to.
cacheEmitter.emit('__global_focus');
}
};

const onOnline = () => {
if (defaultOptions.revalidateOnReconnect) {
cacheEmitter.emit('__global_reconnect');
}
};

if (defaultOptions.revalidateOnFocus) {
window.addEventListener('focus', onFocus);
window.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') onFocus();
});
}

if (defaultOptions.revalidateOnReconnect) {
window.addEventListener('online', onOnline);
}

return () => {
window.removeEventListener('focus', onFocus);
window.removeEventListener('visibilitychange', onFocus);
window.removeEventListener('online', onOnline);
};
}, [defaultOptions.revalidateOnFocus, defaultOptions.revalidateOnReconnect]);

return (
<LocalFetchContext.Provider value={defaultOptions}>
{children}
</LocalFetchContext.Provider>
);
}
Loading
Loading