UNPKG

revali

Version:

Framework-agnostic stale-while-revalidate (SWR) data fetching library. A minimal yet powerful caching library for JavaScript/TypeScript.

882 lines (676 loc) 23.9 kB
<p align="center"> <img src="./docs/revali-logo.svg" alt="Revali Logo" width="80" /> </p> <h1 align="center">Revali</h1> <p align="center"> <a href="https://github.com/cerebralatlas/revali/actions/workflows/ci.yml"> <img src="https://github.com/cerebralatlas/revali/actions/workflows/ci.yml/badge.svg" alt="CI Status" /> </a> <a href="https://www.npmjs.com/package/revali"> <img src="https://img.shields.io/npm/v/revali.svg" alt="NPM Version" /> </a> <a href="https://www.npmjs.com/package/revali"> <img src="https://img.shields.io/npm/dm/revali.svg" alt="NPM Downloads" /> </a> <a href="https://github.com/cerebralatlas/revali/tree/main/tests"> <img src="https://img.shields.io/badge/tests-108%20passing-brightgreen.svg" alt="Tests Passing" /> </a> <a href="https://github.com/cerebralatlas/revali/search?l=typescript"> <img src="https://img.shields.io/badge/TypeScript-100%25-blue.svg" alt="TypeScript" /> </a> <a href="https://github.com/cerebralatlas/revali/blob/main/LICENSE"> <img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /> </a> <a href="https://bundlephobia.com/package/revali"> <img src="https://img.shields.io/bundlephobia/minzip/revali" alt="Bundle Size" /> </a> <a href="https://github.com/cerebralatlas/revali"> <img src="https://img.shields.io/github/stars/cerebralatlas/revali?style=social" alt="GitHub Stars" /> </a> </p> <p align="center"> <strong>Framework-agnostic stale-while-revalidate (SWR) data fetching library</strong><br> A powerful yet lightweight caching library for JavaScript/TypeScript. Works seamlessly with <strong>React, Vue, Svelte</strong>, or vanilla JS projects. </p> --- ## Features - **Stale-While-Revalidate**: Return cached data instantly, then refresh in the background - **Smart caching** with TTL, LRU eviction, and configurable cache size limits - **Auto re-render** when cache updates (via pub/sub pattern) - **Request deduplication**: Prevent duplicate network requests automatically - ️ **Advanced error handling** with exponential backoff retry strategy - ️ **Optimistic updates**: Manual cache mutation with optional revalidation - **Auto revalidation** on window focus and network reconnection (configurable) - **Polling / interval revalidation**: Automatic data refresh at specified intervals - **Memory management**: Automatic cleanup and cache size limits - **Cache introspection**: Built-in cache management and debugging APIs - ️ **TypeScript-first**: Full type safety with zero `any` types - **Framework-agnostic**: Use directly or wrap in React/Vue/Svelte hooks - **Lightweight**: ~1.8KB gzipped (~6KB raw), zero dependencies --- ## Installation ```bash npm install revali ``` ```bash yarn add revali ``` ```bash pnpm add revali ``` ## Quick Start ```typescript import { revaliFetch, subscribe, mutate } from 'revali'; // Fetch data with caching and SWR behavior const userData = await revaliFetch( 'user/1', () => fetch('https://api.example.com/users/1').then((r) => r.json()), { ttl: 5 * 60 * 1000, // 5 minutes cache retries: 3, // Retry failed requests 3 times revalidateOnFocus: true, // Refresh when window regains focus refreshInterval: 30 * 1000, // Poll every 30 seconds }, ); // Subscribe to cache updates (with error handling) const unsubscribe = subscribe('user/1', (data, error) => { if (error) { console.error('Fetch error:', error); } else { console.log('Updated data:', data); } }); // Optimistic updates with automatic revalidation mutate( 'user/1', (prev) => ({ ...prev, name: 'Updated Name', }), true, ); // Will revalidate after mutation // Cache management import { clearCache, getCacheInfo, cleanup } from 'revali'; clearCache('user/1'); // Clear specific cache clearCache(); // Clear all cache console.log(getCacheInfo()); // { size: 5, keys: ["user/1", ...] } // Manual revalidation control import { triggerRevalidation } from 'revali'; triggerRevalidation(); // Manually trigger revalidation for all eligible entries // Clean up everything cleanup(); // Clear all cache and remove all subscribers unsubscribe(); // Or just unsubscribe from specific key ``` ## Framework Integration ### React Hook ```tsx import { useState, useEffect, useCallback } from 'react'; import { revaliFetch, subscribe, mutate, type RevaliOptions } from 'revali'; interface UseRevaliResult<T> { data: T | undefined; error: Error | undefined; isLoading: boolean; isValidating: boolean; mutate: (data: T | ((prev: T | undefined) => T), shouldRevalidate?: boolean) => T; } export function useRevali<T>( key: string, fetcher: () => Promise<T>, options?: RevaliOptions, ): UseRevaliResult<T> { const [data, setData] = useState<T | undefined>(); const [error, setError] = useState<Error | undefined>(); const [isLoading, setIsLoading] = useState(true); const [isValidating, setIsValidating] = useState(false); useEffect(() => { let mounted = true; const loadData = async () => { if (!mounted) return; setIsLoading(true); setIsValidating(true); try { const result = await revaliFetch(key, fetcher, options); if (mounted) { setData(result); setError(undefined); } } catch (err) { if (mounted) { setError(err instanceof Error ? err : new Error(String(err))); } } finally { if (mounted) { setIsLoading(false); setIsValidating(false); } } }; loadData(); // Subscribe to cache updates const unsubscribe = subscribe(key, (newData, newError) => { if (!mounted) return; setData(newData); setError(newError); setIsValidating(false); }); return () => { mounted = false; unsubscribe(); }; }, [key]); const mutateFn = useCallback( (data: T | ((prev: T | undefined) => T), shouldRevalidate = true) => { return mutate(key, data, shouldRevalidate); }, [key], ); return { data, error, isLoading, isValidating, mutate: mutateFn }; } // Usage function UserProfile({ userId }: { userId: string }) { const { data, error, isLoading, mutate } = useRevali( `user/${userId}`, () => fetch(`/api/users/${userId}`).then((r) => r.json()), { ttl: 5 * 60 * 1000 }, ); if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>; return ( <div> <h1>{data?.name}</h1> <button onClick={() => mutate((prev) => ({ ...prev, name: 'New Name' }))}>Update Name</button> </div> ); } ``` ### Vue Composition API ```vue <script setup lang="ts"> import { ref, onMounted, onUnmounted, watch } from 'vue'; import { revaliFetch, subscribe, type RevaliOptions } from 'revali'; interface UseRevaliResult<T> { data: Ref<T | undefined>; error: Ref<Error | undefined>; isLoading: Ref<boolean>; } function useRevali<T>( key: string, fetcher: () => Promise<T>, options?: RevaliOptions, ): UseRevaliResult<T> { const data = ref<T>(); const error = ref<Error>(); const isLoading = ref(true); let unsubscribe: (() => void) | null = null; const load = async () => { isLoading.value = true; try { data.value = await revaliFetch(key, fetcher, options); error.value = undefined; } catch (err) { error.value = err instanceof Error ? err : new Error(String(err)); } finally { isLoading.value = false; } }; onMounted(async () => { await load(); unsubscribe = subscribe(key, (newData, newError) => { data.value = newData; error.value = newError; }); }); onUnmounted(() => { unsubscribe?.(); }); return { data, error, isLoading }; } // Usage const { data: user, error, isLoading, } = useRevali('user/1', () => fetch('/api/user/1').then((r) => r.json())); </script> ``` ## API Reference ### Core Functions #### `revaliFetch<T>(key, fetcher, options?): Promise<T>` The main function for fetching and caching data. ```typescript const data = await revaliFetch('posts/1', () => fetch('/api/posts/1').then((r) => r.json()), { ttl: 5 * 60 * 1000, // Cache for 5 minutes retries: 3, // Retry 3 times on failure retryDelay: 1000, // Initial retry delay maxCacheSize: 50, // Max cache entries revalidateOnFocus: true, // Revalidate on window focus revalidateOnReconnect: true, // Revalidate on network reconnect }); ``` #### `subscribe<T>(key, callback): () => void` Subscribe to cache updates for a specific key. ```typescript const unsubscribe = subscribe('user/1', (data, error) => { if (error) console.error('Error:', error); else console.log('Data updated:', data); }); ``` #### `mutate<T>(key, data, shouldRevalidate?): T` Manually update cache and optionally trigger revalidation. ```typescript // Update with new data mutate('user/1', { id: 1, name: 'John Doe' }); // Update with function mutate('user/1', (prev) => ({ ...prev, name: 'Jane Doe' })); // Update without revalidation mutate('user/1', newData, false); ``` #### `clearCache(key?): void` Clear cache entries. ```typescript clearCache('user/1'); // Clear specific key clearCache(); // Clear all cache ``` #### `getCacheInfo(): { size: number; keys: string[] }` Get information about the current cache state. ```typescript const { size, keys } = getCacheInfo(); console.log(`Cache has ${size} entries:`, keys); ``` #### `cleanup(): void` Clean up all cache entries and remove all subscribers. Useful for complete cleanup. ```typescript import { cleanup } from 'revali'; // Clear everything - cache and subscribers cleanup(); ``` #### `triggerRevalidation(): void` Manually trigger revalidation for all eligible cache entries. ```typescript import { triggerRevalidation } from 'revali'; // Manually revalidate all entries that have revalidateOnFocus or revalidateOnReconnect enabled triggerRevalidation(); ``` #### `initAutoRevalidation(): void` Manually initialize automatic revalidation listeners. This is called automatically when importing Revali, but can be called manually if needed. ```typescript import { initAutoRevalidation } from 'revali'; // Manually set up window focus and network reconnect listeners // (This is done automatically when importing Revali) initAutoRevalidation(); ``` #### `getPollingInfo(): { activeCount: number; keys: string[] }` Get information about active polling tasks. ```typescript import { getPollingInfo } from 'revali'; const pollingInfo = getPollingInfo(); console.log(`Active polling tasks: ${pollingInfo.activeCount}`); console.log('Polling keys:', pollingInfo.keys); ``` #### `hasActivePolling(key: string): boolean` Check if a specific key has active polling. ```typescript import { hasActivePolling } from 'revali'; if (hasActivePolling('user/1')) { console.log('User data is being polled'); } ``` #### `cleanupPolling(): void` Clean up all active polling tasks. Useful for cleanup when your application is shutting down. ```typescript import { cleanupPolling } from 'revali'; // Stop all polling tasks cleanupPolling(); ``` ### TypeScript Types ```typescript export type Fetcher<T> = () => Promise<T>; export type Subscriber<T> = (data: T | undefined, error?: Error) => void; export interface RevaliOptions { retries?: number; // Max retry attempts (default: 2) retryDelay?: number; // Initial retry delay in ms (default: 300) ttl?: number; // Cache TTL in ms (default: 300000 = 5min) maxCacheSize?: number; // Max cache entries (default: 100) revalidateOnFocus?: boolean; // Revalidate on focus (default: true) revalidateOnReconnect?: boolean; // Revalidate on reconnect (default: true) refreshInterval?: number; // Polling interval in ms, 0 means no polling (default: 0) refreshWhenHidden?: boolean; // Continue polling when page is hidden (default: false) refreshWhenOffline?: boolean; // Continue polling when offline (default: false) dedupingInterval?: number; // Deduping interval in ms (default: 2000) } export interface CacheEntry<T> { data: T | undefined; timestamp: number; error?: Error; fetcher: Fetcher<T>; options: RevaliOptions; } export interface RevaliState<T> { data?: T; error?: Error; isLoading: boolean; isValidating: boolean; } // Default configuration constant export const DEFAULT_OPTIONS: Required<RevaliOptions>; ``` ## Comparison with Popular Libraries | Feature | SWR | TanStack Query | Revali 🚀 | | -------------------------- | ------- | -------------- | ---------- | | Bundle Size | ~6KB | ~13KB | **~1.8KB** | | Framework Support | React | Multi | **Any** | | TypeScript-first | ✅ | ✅ | ✅ | | Request Deduplication | ✅ | ✅ | ✅ | | Cache with TTL | ✅ | ✅ | ✅ | | Error Retry + Backoff | ✅ | ✅ | ✅ | | Optimistic Updates | ✅ | ✅ | ✅ | | Background Revalidation | ✅ | ✅ | ✅ | | Focus/Reconnect Revalidate | ✅ | ✅ | ✅ | | Memory Management | Limited | ✅ | ✅ | | Cache Introspection | Limited | ✅ | ✅ | | Zero Dependencies | ❌ | ❌ | **✅** | ## Roadmap ### Completed (v0.2.0) - ✅ Basic cache & subscription system - ✅ Request deduplication - ✅ Error retry with exponential backoff - ✅ Revalidate on focus & network reconnect - ✅ Manual mutate / optimistic updates - ✅ TTL-based cache expiration - ✅ LRU cache eviction - ✅ Memory management & cleanup - ✅ Full TypeScript support - ✅ Cache introspection APIs ### Completed (v0.3.0) - ✅ Polling / interval revalidation - ✅ Request cancellation (AbortController) ### In Progress (v0.3.0) - [ ] Middleware system - [ ] Built-in React/Vue/Svelte hooks ### Future (v0.4.0) - [ ] Pagination & infinite loading - [ ] Offline support with persistence - [ ] DevTools browser extension - [ ] GraphQL integration - [ ] SSR/SSG support ## Advanced Usage ### Auto-Initialization Behavior Revali automatically sets up revalidation listeners when imported: ```typescript import { revaliFetch } from 'revali'; // Automatically listens for: // - Window focus events (revalidates when tab becomes active) // - Network online events (revalidates when connection restored) ``` ### Manual Control You can control revalidation behavior manually: ```typescript import { triggerRevalidation, initAutoRevalidation, DEFAULT_OPTIONS } from 'revali'; // Use default options as base for custom configuration const customOptions = { ...DEFAULT_OPTIONS, ttl: 10 * 60 * 1000, // Override TTL to 10 minutes retries: 5, // Override retries to 5 }; // Manually trigger revalidation triggerRevalidation(); // Access default configuration console.log('Default TTL:', DEFAULT_OPTIONS.ttl); ``` ### Polling / Interval Revalidation Configure automatic data refresh at specified intervals: ```typescript import { revaliFetch, getPollingInfo, hasActivePolling } from 'revali'; // Basic polling - refresh every 30 seconds const liveStats = await revaliFetch( 'live-stats', () => fetch('/api/stats').then(r => r.json()), { refreshInterval: 30 * 1000, // 30 seconds ttl: 5 * 60 * 1000, // 5 minutes cache } ); // Advanced polling configuration const criticalData = await revaliFetch( 'critical-data', fetchCriticalData, { refreshInterval: 5 * 1000, // Poll every 5 seconds refreshWhenHidden: true, // Continue when tab is not active refreshWhenOffline: false, // Pause when offline dedupingInterval: 2000, // Prevent requests closer than 2s ttl: 10 * 1000, // Short cache TTL for fresh data } ); // Check polling status console.log('Polling info:', getPollingInfo()); console.log('Is polling active:', hasActivePolling('live-stats')); // Polling automatically stops when cache is cleared clearCache('live-stats'); // Stops polling for this key ``` #### Polling Best Practices 1. **Choose appropriate intervals**: Balance freshness needs with server load 2. **Use `refreshWhenHidden: false`** for non-critical data to save resources 3. **Configure `dedupingInterval`** to prevent excessive requests 4. **Monitor polling with `getPollingInfo()`** for debugging ### Request Cancellation (AbortController) Revali supports request cancellation using the standard AbortController API, providing fine-grained control over request lifecycle: #### Basic Cancellation ```typescript import { revaliFetch, cancel } from 'revali'; // Cancel a specific request const key = 'user-data'; const promise = revaliFetch(key, async (signal) => { const response = await fetch('/api/user', { signal }); return response.json(); }); // Cancel the request cancel(key); // The promise will reject with CancellationError try { await promise; } catch (error) { if (error.name === 'CancellationError') { console.log('Request was cancelled'); } } ``` #### External AbortController ```typescript import { revaliFetch } from 'revali'; // Use your own AbortController const controller = new AbortController(); const promise = revaliFetch('data', async (signal) => { const response = await fetch('/api/data', { signal }); return response.json(); }, { signal: controller.signal }); // Cancel using your controller controller.abort(); ``` #### Request Timeout ```typescript import { revaliFetch } from 'revali'; // Automatically cancel after timeout const data = await revaliFetch('slow-api', async (signal) => { const response = await fetch('/api/slow', { signal }); return response.json(); }, { abortTimeout: 5000 // Cancel after 5 seconds }); ``` #### Cancel on Revalidate ```typescript import { revaliFetch } from 'revali'; // Cancel previous request when starting new one const searchResults = await revaliFetch(`search-${query}`, async (signal) => { const response = await fetch(`/api/search?q=${query}`, { signal }); return response.json(); }, { abortOnRevalidate: true // Cancel previous search when new search starts }); ``` #### Cancellation API ```typescript import { cancel, cancelAll, isCancelled, getCancellationInfo, isCancellationError } from 'revali'; // Cancel specific request const cancelled = cancel('request-key'); console.log('Cancelled:', cancelled); // Cancel all active requests const cancelledCount = cancelAll(); console.log('Cancelled count:', cancelledCount); // Check if request was cancelled const wasCancelled = isCancelled('request-key'); // Get cancellation information const info = getCancellationInfo(); console.log('Active requests:', info.activeCount); console.log('Active keys:', info.keys); // Check if error is cancellation error try { await someRequest(); } catch (error) { if (isCancellationError(error)) { console.log('Request was cancelled'); } } ``` #### Best Practices for Cancellation 1. **Always handle AbortSignal in your fetchers**: ```typescript const fetcher = async (signal?: AbortSignal) => { const response = await fetch('/api/data', { signal }); if (!response.ok) throw new Error('Failed'); return response.json(); }; ``` 2. **Use `abortOnRevalidate` for search/filter scenarios**: ```typescript const searchData = await revaliFetch(`search-${query}`, fetcher, { abortOnRevalidate: true // Cancel previous search }); ``` 3. **Set reasonable timeouts for slow operations**: ```typescript const heavyComputation = await revaliFetch('compute', fetcher, { abortTimeout: 30000 // 30 second timeout }); ``` 4. **Clean up on component unmount** (React example): ```typescript useEffect(() => { const controller = new AbortController(); revaliFetch('component-data', fetcher, { signal: controller.signal }); return () => controller.abort(); // Clean up on unmount }, []); ``` ### Tree-Shaking Support Thanks to the modular architecture, bundlers can tree-shake unused code: ```typescript // Only imports the functions you use import { revaliFetch, mutate } from 'revali'; // ✅ Other modules (cleanup, revalidation) won't be included in bundle ``` ### Performance Best Practices #### 1. **Optimize Cache Keys** Use consistent, descriptive cache keys: ```typescript // ✅ Good - consistent and descriptive const userId = 123; const userData = await revaliFetch(`user/${userId}`, fetchUser); const userPosts = await revaliFetch(`user/${userId}/posts`, fetchUserPosts); // ❌ Avoid - inconsistent or too generic const userData = await revaliFetch(`user-${userId}`, fetchUser); const userPosts = await revaliFetch(`posts`, fetchUserPosts); ``` #### 2. **Configure Appropriate TTL** Set TTL based on data freshness requirements: ```typescript // Fast-changing data - shorter TTL const liveData = await revaliFetch('live-stats', fetchStats, { ttl: 30 * 1000, // 30 seconds }); // Relatively stable data - longer TTL const userData = await revaliFetch('user/profile', fetchProfile, { ttl: 5 * 60 * 1000, // 5 minutes }); // Static data - very long TTL const appConfig = await revaliFetch('app/config', fetchConfig, { ttl: 60 * 60 * 1000, // 1 hour }); ``` #### 3. **Manage Cache Size** Configure appropriate cache limits: ```typescript const options = { maxCacheSize: 50, // Limit cache entries for memory efficiency ttl: 5 * 60 * 1000, }; ``` #### 4. **Cleanup on Unmount** Always cleanup subscriptions: ```typescript useEffect(() => { const unsubscribe = subscribe(key, handleUpdate); return () => { unsubscribe(); // Prevent memory leaks }; }, [key]); ``` ## Why Revali? **Revali** (from "revalidate") was created to understand how modern data fetching libraries work under the hood, while providing a framework-agnostic solution that's both powerful and lightweight. ### Design Philosophy - **Framework Agnostic**: Works with any UI library or vanilla JavaScript - **TypeScript First**: Built with type safety as a priority - **Zero Dependencies**: No external dependencies, minimal bundle size - **Memory Conscious**: Smart caching with automatic cleanup - **Developer Experience**: Simple API with powerful features - **Modular Architecture**: Clean separation of concerns for better maintainability ## Contributing We welcome contributions! Here's how you can help: - **Report bugs** via [GitHub Issues](https://github.com/cerebralatlas/revali/issues) - **Suggest features** or improvements - **Improve documentation** and examples - **Add tests** for edge cases - **Submit PRs** for bug fixes or features ### Development Setup ```bash git clone https://github.com/cerebralatlas/revali.git cd revali pnpm install # Development pnpm run dev # Build pnpm run build # Test pnpm run test pnpm run test:coverage # Run tests with coverage report pnpm run test:ui # Run tests with UI # Type checking pnpm run type-check # Linting pnpm run lint pnpm run lint:fix ``` ### Project Quality - **98% Test Coverage**: Comprehensive test suite covering all core functionality - **Modular Architecture**: Clean separation of concerns for maintainability - **Zero Dependencies**: No external runtime dependencies ### Bundle Size Breakdown | Format | Raw Size | Gzipped | Minified + Gzipped | | ------ | -------- | ------- | ------------------ | | ESM | ~6.2KB | ~1.8KB | ~1.8KB | | CJS | ~7.6KB | ~2.2KB | ~2.2KB | _Actual network transfer size is the gzipped size, making Revali one of the smallest SWR libraries available._ ## License MIT © [Cerebral Atlas](https://github.com/cerebralatlas) --- **Star this repo if you find it useful!**