@endpoint-fetcher/cache 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +615 -0
- package/dist/index.cjs +139 -0
- package/dist/index.d.cts +186 -0
- package/dist/index.d.ts +186 -0
- package/dist/index.js +113 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lorenzo Giovanni Vecchio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
# endpoint-fetcher-cache
|
|
2
|
+
|
|
3
|
+
A caching plugin for [endpoint-fetcher](https://github.com/lorenzo-vecchio/endpoint-fetcher) that adds intelligent caching with type-safe wrapper support.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
* 🔒 **Fully Type-Safe** - TypeScript support with complete type inference
|
|
8
|
+
* ⚡ **Smart Caching** - Automatic caching with TTL support
|
|
9
|
+
* 🔄 **Manual Refresh** - Programmatically refresh cached data
|
|
10
|
+
* 🗑️ **Cache Invalidation** - Clear specific cache entries
|
|
11
|
+
* 📊 **Cache Metadata** - Know when data was cached and when it expires
|
|
12
|
+
* 🎯 **LRU Eviction** - Automatic eviction of old entries when cache is full
|
|
13
|
+
* 🔧 **Customizable** - Custom TTL, methods, key generation, and storage
|
|
14
|
+
* 💾 **Storage Adapters** - Bring your own storage (localStorage, Redis, etc.)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @endpoint-fetcher/cache
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
**Peer dependency:** `endpoint-fetcher` ^2.0.0
|
|
23
|
+
|
|
24
|
+
## Quick Start
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
import { createApiClient, get } from 'endpoint-fetcher';
|
|
28
|
+
import { cache, CachingWrapper } from 'endpoint-fetcher-cache';
|
|
29
|
+
|
|
30
|
+
type User = { id: string; name: string; email: string };
|
|
31
|
+
|
|
32
|
+
const api = createApiClient({
|
|
33
|
+
users: {
|
|
34
|
+
endpoints: {
|
|
35
|
+
// Important: Output type must be CachingWrapper<T>
|
|
36
|
+
getAll: get<void, CachingWrapper<User[]>>('/users'),
|
|
37
|
+
getById: get<{ id: string }, CachingWrapper<User>>((input) => `/users/${input.id}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}, {
|
|
41
|
+
baseUrl: 'https://api.example.com',
|
|
42
|
+
plugins: [
|
|
43
|
+
cache({ ttl: 300 }) // Cache for 5 minutes
|
|
44
|
+
]
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// First call - fetches from API
|
|
48
|
+
const result = await api.users.getAll();
|
|
49
|
+
console.log(result.data); // User[]
|
|
50
|
+
console.log(result.cachedAt); // Date when cached
|
|
51
|
+
console.log(result.expiresAt); // Date when expires
|
|
52
|
+
console.log(result.isStale); // false
|
|
53
|
+
|
|
54
|
+
// Second call - returns from cache (if within TTL)
|
|
55
|
+
const cached = await api.users.getAll();
|
|
56
|
+
console.log(cached.cachedAt === result.cachedAt); // true (same cache)
|
|
57
|
+
|
|
58
|
+
// Manually refresh
|
|
59
|
+
const refreshed = await result.refresh();
|
|
60
|
+
console.log(refreshed.data); // Fresh data from API
|
|
61
|
+
|
|
62
|
+
// Invalidate cache
|
|
63
|
+
result.invalidate();
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## API Reference
|
|
67
|
+
|
|
68
|
+
### `cache(config?)`
|
|
69
|
+
|
|
70
|
+
Creates a caching plugin for endpoint-fetcher.
|
|
71
|
+
|
|
72
|
+
**Parameters:**
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
{
|
|
76
|
+
ttl?: number; // Time to live in seconds (default: 300)
|
|
77
|
+
methods?: string[]; // HTTP methods to cache (default: ['GET'])
|
|
78
|
+
maxSize?: number; // Max cache entries (default: Infinity)
|
|
79
|
+
keyGenerator?: (method: string, path: string, input: any) => string;
|
|
80
|
+
storage?: CacheStorage; // Custom storage adapter
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
**Returns:** Plugin instance for use with endpoint-fetcher
|
|
85
|
+
|
|
86
|
+
### `CachingWrapper<T>`
|
|
87
|
+
|
|
88
|
+
The wrapper type that adds caching metadata to your response type.
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
type CachingWrapper<T> = {
|
|
92
|
+
data: T; // The actual data
|
|
93
|
+
cachedAt: Date; // When it was cached
|
|
94
|
+
expiresAt: Date; // When it expires
|
|
95
|
+
isStale: boolean; // Whether it's expired
|
|
96
|
+
refresh: () => Promise<CachingWrapper<T>>; // Refresh the data
|
|
97
|
+
invalidate: () => void; // Remove from cache
|
|
98
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### `CacheStorage` Interface
|
|
102
|
+
|
|
103
|
+
Implement this interface to create custom storage adapters:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
interface CacheStorage {
|
|
107
|
+
get(key: string): CacheEntry | undefined;
|
|
108
|
+
set(key: string, value: CacheEntry): void;
|
|
109
|
+
delete(key: string): void;
|
|
110
|
+
clear(): void;
|
|
111
|
+
keys(): string[];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface CacheEntry {
|
|
115
|
+
data: any;
|
|
116
|
+
cachedAt: Date;
|
|
117
|
+
expiresAt: Date;
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Usage Examples
|
|
122
|
+
|
|
123
|
+
### Basic Caching
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { createApiClient, get } from 'endpoint-fetcher';
|
|
127
|
+
import { cache, CachingWrapper } from 'endpoint-fetcher-cache';
|
|
128
|
+
|
|
129
|
+
type Post = { id: string; title: string; content: string };
|
|
130
|
+
|
|
131
|
+
const api = createApiClient({
|
|
132
|
+
posts: {
|
|
133
|
+
endpoints: {
|
|
134
|
+
getAll: get<void, CachingWrapper<Post[]>>('/posts'),
|
|
135
|
+
getById: get<{ id: string }, CachingWrapper<Post>>((input) => `/posts/${input.id}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}, {
|
|
139
|
+
baseUrl: 'https://api.example.com',
|
|
140
|
+
plugins: [cache({ ttl: 600 })] // 10 minutes
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const posts = await api.posts.getAll();
|
|
144
|
+
console.log('Cached at:', posts.cachedAt);
|
|
145
|
+
console.log('Expires at:', posts.expiresAt);
|
|
146
|
+
console.log('Data:', posts.data);
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Custom TTL Per Endpoint
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
// You can use multiple cache plugin instances for different TTLs
|
|
153
|
+
const api = createApiClient({
|
|
154
|
+
fastChanging: {
|
|
155
|
+
endpoints: {
|
|
156
|
+
getData: get<void, CachingWrapper<Data>>('/fast')
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
slowChanging: {
|
|
160
|
+
endpoints: {
|
|
161
|
+
getData: get<void, CachingWrapper<Data>>('/slow')
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}, {
|
|
165
|
+
baseUrl: 'https://api.example.com',
|
|
166
|
+
plugins: [
|
|
167
|
+
cache({ ttl: 60 }) // Default: 1 minute
|
|
168
|
+
]
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Or apply at group level
|
|
172
|
+
const api2 = createApiClient({
|
|
173
|
+
fast: group({
|
|
174
|
+
endpoints: {
|
|
175
|
+
getData: get<void, CachingWrapper<Data>>('/fast')
|
|
176
|
+
}
|
|
177
|
+
}),
|
|
178
|
+
slow: group({
|
|
179
|
+
endpoints: {
|
|
180
|
+
getData: get<void, CachingWrapper<Data>>('/slow')
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
}, {
|
|
184
|
+
baseUrl: 'https://api.example.com',
|
|
185
|
+
plugins: [cache({ ttl: 300 })]
|
|
186
|
+
});
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
### Checking Cache Freshness
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
const result = await api.users.getById({ id: '123' });
|
|
193
|
+
|
|
194
|
+
if (result.isStale) {
|
|
195
|
+
console.log('Data is stale, consider refreshing');
|
|
196
|
+
const fresh = await result.refresh();
|
|
197
|
+
console.log('Refreshed data:', fresh.data);
|
|
198
|
+
} else {
|
|
199
|
+
console.log('Data is fresh:', result.data);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Calculate remaining cache time
|
|
203
|
+
const now = new Date();
|
|
204
|
+
const remainingMs = result.expiresAt.getTime() - now.getTime();
|
|
205
|
+
const remainingSec = Math.floor(remainingMs / 1000);
|
|
206
|
+
console.log(`Cache expires in ${remainingSec} seconds`);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### Manual Cache Management
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const result = await api.users.getAll();
|
|
213
|
+
|
|
214
|
+
// Force refresh (bypasses cache)
|
|
215
|
+
const refreshed = await result.refresh();
|
|
216
|
+
console.log('Fresh data:', refreshed.data);
|
|
217
|
+
|
|
218
|
+
// Invalidate specific cache entry
|
|
219
|
+
result.invalidate();
|
|
220
|
+
|
|
221
|
+
// Next call will fetch from API
|
|
222
|
+
const fresh = await api.users.getAll();
|
|
223
|
+
console.log('Fetched from API:', fresh.data);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Cache Specific Methods
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
const api = createApiClient({
|
|
230
|
+
users: {
|
|
231
|
+
endpoints: {
|
|
232
|
+
getAll: get<void, CachingWrapper<User[]>>('/users'),
|
|
233
|
+
create: post<CreateUserInput, CachingWrapper<User>>('/users')
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}, {
|
|
237
|
+
baseUrl: 'https://api.example.com',
|
|
238
|
+
plugins: [
|
|
239
|
+
cache({
|
|
240
|
+
ttl: 300,
|
|
241
|
+
methods: ['GET', 'POST'] // Cache both GET and POST
|
|
242
|
+
})
|
|
243
|
+
]
|
|
244
|
+
});
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
### Custom Cache Key Generation
|
|
248
|
+
|
|
249
|
+
```typescript
|
|
250
|
+
const api = createApiClient({
|
|
251
|
+
users: {
|
|
252
|
+
endpoints: {
|
|
253
|
+
getAll: get<{ page?: number }, CachingWrapper<User[]>>('/users')
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}, {
|
|
257
|
+
baseUrl: 'https://api.example.com',
|
|
258
|
+
plugins: [
|
|
259
|
+
cache({
|
|
260
|
+
ttl: 300,
|
|
261
|
+
// Custom key generator ignores page parameter
|
|
262
|
+
keyGenerator: (method, path, input) => {
|
|
263
|
+
return `${method}:${path}`; // Ignores input
|
|
264
|
+
}
|
|
265
|
+
})
|
|
266
|
+
]
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// Both calls use same cache entry
|
|
270
|
+
const page1 = await api.users.getAll({ page: 1 });
|
|
271
|
+
const page2 = await api.users.getAll({ page: 2 });
|
|
272
|
+
console.log(page1.cachedAt === page2.cachedAt); // true
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
### Limited Cache Size (LRU)
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
const api = createApiClient({
|
|
279
|
+
users: {
|
|
280
|
+
endpoints: {
|
|
281
|
+
getById: get<{ id: string }, CachingWrapper<User>>((input) => `/users/${input.id}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}, {
|
|
285
|
+
baseUrl: 'https://api.example.com',
|
|
286
|
+
plugins: [
|
|
287
|
+
cache({
|
|
288
|
+
ttl: 600,
|
|
289
|
+
maxSize: 100 // Keep only 100 most recently used entries
|
|
290
|
+
})
|
|
291
|
+
]
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// When cache exceeds 100 entries, oldest entries are removed
|
|
295
|
+
for (let i = 0; i < 150; i++) {
|
|
296
|
+
await api.users.getById({ id: i.toString() });
|
|
297
|
+
}
|
|
298
|
+
// Only last 100 users are cached
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
### Custom Storage (localStorage)
|
|
302
|
+
|
|
303
|
+
```typescript
|
|
304
|
+
import { cache, CacheStorage, CacheEntry } from 'endpoint-fetcher-cache';
|
|
305
|
+
|
|
306
|
+
class LocalStorageCacheAdapter implements CacheStorage {
|
|
307
|
+
private prefix = 'api-cache:';
|
|
308
|
+
|
|
309
|
+
get(key: string): CacheEntry | undefined {
|
|
310
|
+
const item = localStorage.getItem(this.prefix + key);
|
|
311
|
+
if (!item) return undefined;
|
|
312
|
+
|
|
313
|
+
const entry = JSON.parse(item);
|
|
314
|
+
return {
|
|
315
|
+
data: entry.data,
|
|
316
|
+
cachedAt: new Date(entry.cachedAt),
|
|
317
|
+
expiresAt: new Date(entry.expiresAt)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
set(key: string, value: CacheEntry): void {
|
|
322
|
+
localStorage.setItem(
|
|
323
|
+
this.prefix + key,
|
|
324
|
+
JSON.stringify({
|
|
325
|
+
data: value.data,
|
|
326
|
+
cachedAt: value.cachedAt.toISOString(),
|
|
327
|
+
expiresAt: value.expiresAt.toISOString()
|
|
328
|
+
})
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
delete(key: string): void {
|
|
333
|
+
localStorage.removeItem(this.prefix + key);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
clear(): void {
|
|
337
|
+
const keys = this.keys();
|
|
338
|
+
keys.forEach(key => localStorage.removeItem(this.prefix + key));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
keys(): string[] {
|
|
342
|
+
const keys: string[] = [];
|
|
343
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
344
|
+
const key = localStorage.key(i);
|
|
345
|
+
if (key?.startsWith(this.prefix)) {
|
|
346
|
+
keys.push(key.substring(this.prefix.length));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return keys;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const api = createApiClient({
|
|
354
|
+
users: {
|
|
355
|
+
endpoints: {
|
|
356
|
+
getAll: get<void, CachingWrapper<User[]>>('/users')
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}, {
|
|
360
|
+
baseUrl: 'https://api.example.com',
|
|
361
|
+
plugins: [
|
|
362
|
+
cache({
|
|
363
|
+
ttl: 3600,
|
|
364
|
+
storage: new LocalStorageCacheAdapter()
|
|
365
|
+
})
|
|
366
|
+
]
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Cache persists across page reloads!
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### Combining with Other Plugins
|
|
373
|
+
|
|
374
|
+
```typescript
|
|
375
|
+
import { createApiClient, get } from 'endpoint-fetcher';
|
|
376
|
+
import { cache, CachingWrapper } from 'endpoint-fetcher-cache';
|
|
377
|
+
import { retry } from 'endpoint-fetcher-retry'; // hypothetical
|
|
378
|
+
|
|
379
|
+
const api = createApiClient({
|
|
380
|
+
users: {
|
|
381
|
+
endpoints: {
|
|
382
|
+
getAll: get<void, CachingWrapper<User[]>>('/users')
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}, {
|
|
386
|
+
baseUrl: 'https://api.example.com',
|
|
387
|
+
plugins: [
|
|
388
|
+
retry({ maxRetries: 3 }), // Retry failed requests
|
|
389
|
+
cache({ ttl: 300 }) // Then cache successful responses
|
|
390
|
+
]
|
|
391
|
+
});
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### React Hook Example
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import { useState, useEffect } from 'react';
|
|
398
|
+
import { api } from './api';
|
|
399
|
+
import type { CachingWrapper } from 'endpoint-fetcher-cache';
|
|
400
|
+
|
|
401
|
+
function useUsers() {
|
|
402
|
+
const [result, setResult] = useState<CachingWrapper<User[]> | null>(null);
|
|
403
|
+
const [loading, setLoading] = useState(true);
|
|
404
|
+
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
api.users.getAll().then(data => {
|
|
407
|
+
setResult(data);
|
|
408
|
+
setLoading(false);
|
|
409
|
+
});
|
|
410
|
+
}, []);
|
|
411
|
+
|
|
412
|
+
const refresh = async () => {
|
|
413
|
+
if (result) {
|
|
414
|
+
setLoading(true);
|
|
415
|
+
const fresh = await result.refresh();
|
|
416
|
+
setResult(fresh);
|
|
417
|
+
setLoading(false);
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
users: result?.data,
|
|
423
|
+
cachedAt: result?.cachedAt,
|
|
424
|
+
isStale: result?.isStale,
|
|
425
|
+
loading,
|
|
426
|
+
refresh
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Usage in component
|
|
431
|
+
function UsersPage() {
|
|
432
|
+
const { users, cachedAt, isStale, loading, refresh } = useUsers();
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<div>
|
|
436
|
+
<button onClick={refresh}>Refresh</button>
|
|
437
|
+
{loading && <p>Loading...</p>}
|
|
438
|
+
{cachedAt && <p>Last updated: {cachedAt.toLocaleString()}</p>}
|
|
439
|
+
{isStale && <p>⚠️ Data is stale</p>}
|
|
440
|
+
<ul>
|
|
441
|
+
{users?.map(user => <li key={user.id}>{user.name}</li>)}
|
|
442
|
+
</ul>
|
|
443
|
+
</div>
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
## How It Works
|
|
449
|
+
|
|
450
|
+
### Type Transformation
|
|
451
|
+
|
|
452
|
+
The plugin uses TypeScript's type system to transform your endpoint's return type:
|
|
453
|
+
|
|
454
|
+
1. **You specify** : `get<void, CachingWrapper<User[]>>('/users')`
|
|
455
|
+
2. **API returns** : `User[]`
|
|
456
|
+
3. **Plugin wraps it** : Adds metadata and methods
|
|
457
|
+
4. **You receive** : `CachingWrapper<User[]>` with full type safety
|
|
458
|
+
|
|
459
|
+
### Cache Key Generation
|
|
460
|
+
|
|
461
|
+
By default, cache keys are generated from:
|
|
462
|
+
|
|
463
|
+
* HTTP method
|
|
464
|
+
* Request path
|
|
465
|
+
* Request input (JSON stringified)
|
|
466
|
+
|
|
467
|
+
Example: `GET:/users/123:{"includeProfile":true}`
|
|
468
|
+
|
|
469
|
+
### LRU Eviction
|
|
470
|
+
|
|
471
|
+
When `maxSize` is set, the cache uses Least Recently Used (LRU) eviction:
|
|
472
|
+
|
|
473
|
+
1. When cache reaches `maxSize` and a new entry is added
|
|
474
|
+
2. The least recently accessed entry is removed
|
|
475
|
+
3. Access order is updated on every `get()` operation
|
|
476
|
+
|
|
477
|
+
## TypeScript
|
|
478
|
+
|
|
479
|
+
The plugin is written in TypeScript and provides complete type safety:
|
|
480
|
+
|
|
481
|
+
```typescript
|
|
482
|
+
import { cache, CachingWrapper } from 'endpoint-fetcher-cache';
|
|
483
|
+
|
|
484
|
+
// ✅ Correct - output type is CachingWrapper<User[]>
|
|
485
|
+
const getUsers = get<void, CachingWrapper<User[]>>('/users');
|
|
486
|
+
|
|
487
|
+
// ❌ Type error - output type must be CachingWrapper<T>
|
|
488
|
+
const getUsers = get<void, User[]>('/users');
|
|
489
|
+
// If you use the cache plugin, TypeScript will complain because
|
|
490
|
+
// User[] !== CachingWrapper<User[]>
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
## Best Practices
|
|
494
|
+
|
|
495
|
+
### 1. Use Appropriate TTL
|
|
496
|
+
|
|
497
|
+
Choose TTL based on how frequently your data changes:
|
|
498
|
+
|
|
499
|
+
* **Static data** : 3600+ seconds (1+ hour)
|
|
500
|
+
* **Slow-changing** : 600-1800 seconds (10-30 minutes)
|
|
501
|
+
* **Medium** : 300-600 seconds (5-10 minutes)
|
|
502
|
+
* **Fast-changing** : 60-300 seconds (1-5 minutes)
|
|
503
|
+
* **Real-time** : Don't cache or use very short TTL (10-30 seconds)
|
|
504
|
+
|
|
505
|
+
### 2. Selective Caching
|
|
506
|
+
|
|
507
|
+
Only cache GET requests by default:
|
|
508
|
+
|
|
509
|
+
```typescript
|
|
510
|
+
cache({ methods: ['GET'] }) // Default
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
Only cache POST if responses are deterministic and safe to cache.
|
|
514
|
+
|
|
515
|
+
### 3. Monitor Cache Size
|
|
516
|
+
|
|
517
|
+
Set `maxSize` to prevent unbounded memory growth:
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
cache({
|
|
521
|
+
ttl: 300,
|
|
522
|
+
maxSize: 1000 // Reasonable limit
|
|
523
|
+
})
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
### 4. Invalidate on Mutations
|
|
527
|
+
|
|
528
|
+
Invalidate relevant cache entries after mutations:
|
|
529
|
+
|
|
530
|
+
```typescript
|
|
531
|
+
const users = await api.users.getAll();
|
|
532
|
+
|
|
533
|
+
// After creating a user
|
|
534
|
+
await api.users.create({ name: 'John', email: 'john@example.com' });
|
|
535
|
+
users.invalidate(); // Clear cached users list
|
|
536
|
+
|
|
537
|
+
// Fetch fresh list
|
|
538
|
+
const updated = await api.users.getAll();
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
### 5. Handle Stale Data
|
|
542
|
+
|
|
543
|
+
Check `isStale` and refresh when needed:
|
|
544
|
+
|
|
545
|
+
```typescript
|
|
546
|
+
const result = await api.users.getAll();
|
|
547
|
+
|
|
548
|
+
if (result.isStale) {
|
|
549
|
+
const fresh = await result.refresh();
|
|
550
|
+
// Use fresh.data
|
|
551
|
+
} else {
|
|
552
|
+
// Use result.data
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
## Advanced Patterns
|
|
557
|
+
|
|
558
|
+
### Automatic Refresh on Stale
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
async function getUsers() {
|
|
562
|
+
const result = await api.users.getAll();
|
|
563
|
+
|
|
564
|
+
if (result.isStale) {
|
|
565
|
+
return (await result.refresh()).data;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return result.data;
|
|
569
|
+
}
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
### Cache Warming
|
|
573
|
+
|
|
574
|
+
```typescript
|
|
575
|
+
// Warm cache on app startup
|
|
576
|
+
async function warmCache() {
|
|
577
|
+
await api.users.getAll();
|
|
578
|
+
await api.posts.getAll();
|
|
579
|
+
console.log('Cache warmed');
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
warmCache();
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
### Conditional Caching
|
|
586
|
+
|
|
587
|
+
```typescript
|
|
588
|
+
const api = createApiClient({
|
|
589
|
+
users: {
|
|
590
|
+
endpoints: {
|
|
591
|
+
// Cache list
|
|
592
|
+
getAll: get<void, CachingWrapper<User[]>>('/users'),
|
|
593
|
+
// Don't cache individual users (use different plugin instance)
|
|
594
|
+
getById: get<{ id: string }, User>((input) => `/users/${input.id}`)
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}, {
|
|
598
|
+
baseUrl: 'https://api.example.com',
|
|
599
|
+
plugins: [
|
|
600
|
+
cache({ ttl: 300, methods: ['GET'] })
|
|
601
|
+
]
|
|
602
|
+
});
|
|
603
|
+
```
|
|
604
|
+
|
|
605
|
+
## License
|
|
606
|
+
|
|
607
|
+
MIT
|
|
608
|
+
|
|
609
|
+
## Contributing
|
|
610
|
+
|
|
611
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
612
|
+
|
|
613
|
+
## Related
|
|
614
|
+
|
|
615
|
+
* [endpoint-fetcher](https://github.com/lorenzo-vecchio/endpoint-fetcher) - The main library
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
cache: () => cache,
|
|
24
|
+
clearCache: () => clearCache,
|
|
25
|
+
default: () => index_default
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(index_exports);
|
|
28
|
+
var import_endpoint_fetcher = require("endpoint-fetcher");
|
|
29
|
+
var InMemoryCacheStorage = class {
|
|
30
|
+
constructor(maxSize = Infinity) {
|
|
31
|
+
this.maxSize = maxSize;
|
|
32
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
33
|
+
this.accessOrder = [];
|
|
34
|
+
}
|
|
35
|
+
get(key) {
|
|
36
|
+
const entry = this.cache.get(key);
|
|
37
|
+
if (entry) {
|
|
38
|
+
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
39
|
+
this.accessOrder.push(key);
|
|
40
|
+
}
|
|
41
|
+
return entry;
|
|
42
|
+
}
|
|
43
|
+
set(key, value) {
|
|
44
|
+
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
|
45
|
+
const oldestKey = this.accessOrder.shift();
|
|
46
|
+
if (oldestKey) {
|
|
47
|
+
this.cache.delete(oldestKey);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
this.cache.set(key, value);
|
|
51
|
+
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
52
|
+
this.accessOrder.push(key);
|
|
53
|
+
}
|
|
54
|
+
delete(key) {
|
|
55
|
+
this.cache.delete(key);
|
|
56
|
+
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
57
|
+
}
|
|
58
|
+
clear() {
|
|
59
|
+
this.cache.clear();
|
|
60
|
+
this.accessOrder = [];
|
|
61
|
+
}
|
|
62
|
+
keys() {
|
|
63
|
+
return Array.from(this.cache.keys());
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
var defaultKeyGenerator = (method, path, input) => {
|
|
67
|
+
const inputStr = input === void 0 || input === null ? "" : JSON.stringify(input);
|
|
68
|
+
return `${method}:${path}:${inputStr}`;
|
|
69
|
+
};
|
|
70
|
+
var cache = (0, import_endpoint_fetcher.createPlugin)((config = {}) => {
|
|
71
|
+
const {
|
|
72
|
+
ttl = 300,
|
|
73
|
+
methods = ["GET"],
|
|
74
|
+
maxSize = Infinity,
|
|
75
|
+
keyGenerator = defaultKeyGenerator,
|
|
76
|
+
storage = new InMemoryCacheStorage(maxSize)
|
|
77
|
+
} = config;
|
|
78
|
+
return {
|
|
79
|
+
handlerWrapper: (originalHandler) => {
|
|
80
|
+
return async (input, context) => {
|
|
81
|
+
if (!methods.includes(context.method)) {
|
|
82
|
+
return originalHandler(input, context);
|
|
83
|
+
}
|
|
84
|
+
const cacheKey = keyGenerator(context.method, context.path, input);
|
|
85
|
+
const createWrapper = (data, cachedAt2, expiresAt2) => {
|
|
86
|
+
const wrapper = {
|
|
87
|
+
data,
|
|
88
|
+
cachedAt: cachedAt2,
|
|
89
|
+
expiresAt: expiresAt2,
|
|
90
|
+
get isStale() {
|
|
91
|
+
return /* @__PURE__ */ new Date() > expiresAt2;
|
|
92
|
+
},
|
|
93
|
+
refresh: async () => {
|
|
94
|
+
storage.delete(cacheKey);
|
|
95
|
+
const freshData = await originalHandler(input, context);
|
|
96
|
+
const newCachedAt = /* @__PURE__ */ new Date();
|
|
97
|
+
const newExpiresAt = new Date(newCachedAt.getTime() + ttl * 1e3);
|
|
98
|
+
storage.set(cacheKey, {
|
|
99
|
+
data: freshData,
|
|
100
|
+
cachedAt: newCachedAt,
|
|
101
|
+
expiresAt: newExpiresAt
|
|
102
|
+
});
|
|
103
|
+
return createWrapper(freshData, newCachedAt, newExpiresAt);
|
|
104
|
+
},
|
|
105
|
+
invalidate: () => {
|
|
106
|
+
storage.delete(cacheKey);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
return wrapper;
|
|
110
|
+
};
|
|
111
|
+
const cached = storage.get(cacheKey);
|
|
112
|
+
const now = /* @__PURE__ */ new Date();
|
|
113
|
+
if (cached && now < cached.expiresAt) {
|
|
114
|
+
return createWrapper(cached.data, cached.cachedAt, cached.expiresAt);
|
|
115
|
+
}
|
|
116
|
+
const result = await originalHandler(input, context);
|
|
117
|
+
const cachedAt = /* @__PURE__ */ new Date();
|
|
118
|
+
const expiresAt = new Date(cachedAt.getTime() + ttl * 1e3);
|
|
119
|
+
storage.set(cacheKey, {
|
|
120
|
+
data: result,
|
|
121
|
+
cachedAt,
|
|
122
|
+
expiresAt
|
|
123
|
+
});
|
|
124
|
+
return createWrapper(result, cachedAt, expiresAt);
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
var clearCache = (storage) => {
|
|
130
|
+
if (storage) {
|
|
131
|
+
storage.clear();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
var index_default = cache;
|
|
135
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
136
|
+
0 && (module.exports = {
|
|
137
|
+
cache,
|
|
138
|
+
clearCache
|
|
139
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { PluginOptions } from 'endpoint-fetcher';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrapper type that adds caching metadata and methods to the response
|
|
5
|
+
*
|
|
6
|
+
* @template T - The actual data type being cached
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const api = createApiClient({
|
|
11
|
+
* users: {
|
|
12
|
+
* endpoints: {
|
|
13
|
+
* getAll: get<void, CachingWrapper<User[]>>('/users')
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
* }, {
|
|
17
|
+
* baseUrl: 'https://api.example.com',
|
|
18
|
+
* plugins: [cache({ ttl: 300 })]
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* const result = await api.users.getAll();
|
|
22
|
+
* console.log(result.data); // User[]
|
|
23
|
+
* console.log(result.cachedAt); // Date
|
|
24
|
+
* console.log(result.isStale); // boolean
|
|
25
|
+
* await result.refresh(); // Refresh the cache
|
|
26
|
+
* result.invalidate(); // Clear from cache
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
type CachingWrapper<T> = {
|
|
30
|
+
/**
|
|
31
|
+
* The actual cached data
|
|
32
|
+
*/
|
|
33
|
+
data: T;
|
|
34
|
+
/**
|
|
35
|
+
* When this data was cached
|
|
36
|
+
*/
|
|
37
|
+
cachedAt: Date;
|
|
38
|
+
/**
|
|
39
|
+
* When this cache entry will expire
|
|
40
|
+
*/
|
|
41
|
+
expiresAt: Date;
|
|
42
|
+
/**
|
|
43
|
+
* Whether this cache entry has expired
|
|
44
|
+
*/
|
|
45
|
+
isStale: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Refresh the data by fetching it again and updating the cache
|
|
48
|
+
*/
|
|
49
|
+
refresh: () => Promise<CachingWrapper<T>>;
|
|
50
|
+
/**
|
|
51
|
+
* Remove this entry from the cache
|
|
52
|
+
*/
|
|
53
|
+
invalidate: () => void;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Configuration options for the caching plugin
|
|
57
|
+
*/
|
|
58
|
+
type CachePluginConfig = {
|
|
59
|
+
/**
|
|
60
|
+
* Time to live in seconds - how long cached data remains valid
|
|
61
|
+
* @default 300 (5 minutes)
|
|
62
|
+
*/
|
|
63
|
+
ttl?: number;
|
|
64
|
+
/**
|
|
65
|
+
* HTTP methods to cache
|
|
66
|
+
* @default ['GET']
|
|
67
|
+
*/
|
|
68
|
+
methods?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* Maximum number of cache entries to store
|
|
71
|
+
* When exceeded, oldest entries are removed (LRU)
|
|
72
|
+
* @default Infinity (no limit)
|
|
73
|
+
*/
|
|
74
|
+
maxSize?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Custom cache key generator
|
|
77
|
+
* By default uses: `${method}:${path}:${JSON.stringify(input)}`
|
|
78
|
+
*
|
|
79
|
+
* @param method - HTTP method
|
|
80
|
+
* @param path - Request path
|
|
81
|
+
* @param input - Request input/body
|
|
82
|
+
* @returns Cache key string
|
|
83
|
+
*/
|
|
84
|
+
keyGenerator?: (method: string, path: string, input: any) => string;
|
|
85
|
+
/**
|
|
86
|
+
* Custom storage adapter (useful for persistent caching)
|
|
87
|
+
* @default In-memory Map
|
|
88
|
+
*/
|
|
89
|
+
storage?: CacheStorage;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Cache storage interface
|
|
93
|
+
*/
|
|
94
|
+
interface CacheStorage {
|
|
95
|
+
get(key: string): CacheEntry | undefined;
|
|
96
|
+
set(key: string, value: CacheEntry): void;
|
|
97
|
+
delete(key: string): void;
|
|
98
|
+
clear(): void;
|
|
99
|
+
keys(): string[];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Cache entry structure
|
|
103
|
+
*/
|
|
104
|
+
interface CacheEntry {
|
|
105
|
+
data: any;
|
|
106
|
+
cachedAt: Date;
|
|
107
|
+
expiresAt: Date;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Caching plugin for endpoint-fetcher
|
|
111
|
+
*
|
|
112
|
+
* Wraps API responses with caching metadata and methods, allowing you to:
|
|
113
|
+
* - Cache responses in memory
|
|
114
|
+
* - Check cache freshness
|
|
115
|
+
* - Manually refresh cached data
|
|
116
|
+
* - Invalidate specific cache entries
|
|
117
|
+
*
|
|
118
|
+
* @param config - Plugin configuration
|
|
119
|
+
* @returns Plugin options for endpoint-fetcher
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* Basic usage:
|
|
123
|
+
* ```typescript
|
|
124
|
+
* import { createApiClient, get } from 'endpoint-fetcher';
|
|
125
|
+
* import { cache, CachingWrapper } from 'endpoint-fetcher-cache';
|
|
126
|
+
*
|
|
127
|
+
* const api = createApiClient({
|
|
128
|
+
* users: {
|
|
129
|
+
* endpoints: {
|
|
130
|
+
* // Output type must be CachingWrapper<T>
|
|
131
|
+
* getAll: get<void, CachingWrapper<User[]>>('/users'),
|
|
132
|
+
* getById: get<{ id: string }, CachingWrapper<User>>((input) => `/users/${input.id}`)
|
|
133
|
+
* }
|
|
134
|
+
* }
|
|
135
|
+
* }, {
|
|
136
|
+
* baseUrl: 'https://api.example.com',
|
|
137
|
+
* plugins: [
|
|
138
|
+
* cache({ ttl: 300 }) // Cache for 5 minutes
|
|
139
|
+
* ]
|
|
140
|
+
* });
|
|
141
|
+
*
|
|
142
|
+
* const result = await api.users.getAll();
|
|
143
|
+
* console.log(result.data); // User[]
|
|
144
|
+
* console.log(result.cachedAt); // Date
|
|
145
|
+
* console.log(result.isStale); // boolean
|
|
146
|
+
* await result.refresh(); // Force refresh
|
|
147
|
+
* result.invalidate(); // Clear from cache
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* Advanced usage with custom storage:
|
|
152
|
+
* ```typescript
|
|
153
|
+
* import { cache } from 'endpoint-fetcher-cache';
|
|
154
|
+
*
|
|
155
|
+
* const api = createApiClient({
|
|
156
|
+
* // ... endpoints
|
|
157
|
+
* }, {
|
|
158
|
+
* baseUrl: 'https://api.example.com',
|
|
159
|
+
* plugins: [
|
|
160
|
+
* cache({
|
|
161
|
+
* ttl: 600, // 10 minutes
|
|
162
|
+
* methods: ['GET', 'POST'], // Cache GET and POST
|
|
163
|
+
* maxSize: 100, // Store max 100 entries
|
|
164
|
+
* keyGenerator: (method, path, input) => {
|
|
165
|
+
* // Custom key generation
|
|
166
|
+
* return `custom:${method}:${path}`;
|
|
167
|
+
* }
|
|
168
|
+
* })
|
|
169
|
+
* ]
|
|
170
|
+
* });
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
declare const cache: (() => PluginOptions) | ((config: CachePluginConfig) => PluginOptions);
|
|
174
|
+
/**
|
|
175
|
+
* Clears all cache entries
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```typescript
|
|
179
|
+
* import { clearCache } from 'endpoint-fetcher-cache';
|
|
180
|
+
*
|
|
181
|
+
* clearCache(); // Clears all cached data
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
declare const clearCache: (storage?: CacheStorage) => void;
|
|
185
|
+
|
|
186
|
+
export { type CacheEntry, type CachePluginConfig, type CacheStorage, type CachingWrapper, cache, clearCache, cache as default };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { PluginOptions } from 'endpoint-fetcher';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrapper type that adds caching metadata and methods to the response
|
|
5
|
+
*
|
|
6
|
+
* @template T - The actual data type being cached
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const api = createApiClient({
|
|
11
|
+
* users: {
|
|
12
|
+
* endpoints: {
|
|
13
|
+
* getAll: get<void, CachingWrapper<User[]>>('/users')
|
|
14
|
+
* }
|
|
15
|
+
* }
|
|
16
|
+
* }, {
|
|
17
|
+
* baseUrl: 'https://api.example.com',
|
|
18
|
+
* plugins: [cache({ ttl: 300 })]
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* const result = await api.users.getAll();
|
|
22
|
+
* console.log(result.data); // User[]
|
|
23
|
+
* console.log(result.cachedAt); // Date
|
|
24
|
+
* console.log(result.isStale); // boolean
|
|
25
|
+
* await result.refresh(); // Refresh the cache
|
|
26
|
+
* result.invalidate(); // Clear from cache
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
type CachingWrapper<T> = {
|
|
30
|
+
/**
|
|
31
|
+
* The actual cached data
|
|
32
|
+
*/
|
|
33
|
+
data: T;
|
|
34
|
+
/**
|
|
35
|
+
* When this data was cached
|
|
36
|
+
*/
|
|
37
|
+
cachedAt: Date;
|
|
38
|
+
/**
|
|
39
|
+
* When this cache entry will expire
|
|
40
|
+
*/
|
|
41
|
+
expiresAt: Date;
|
|
42
|
+
/**
|
|
43
|
+
* Whether this cache entry has expired
|
|
44
|
+
*/
|
|
45
|
+
isStale: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Refresh the data by fetching it again and updating the cache
|
|
48
|
+
*/
|
|
49
|
+
refresh: () => Promise<CachingWrapper<T>>;
|
|
50
|
+
/**
|
|
51
|
+
* Remove this entry from the cache
|
|
52
|
+
*/
|
|
53
|
+
invalidate: () => void;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Configuration options for the caching plugin
|
|
57
|
+
*/
|
|
58
|
+
type CachePluginConfig = {
|
|
59
|
+
/**
|
|
60
|
+
* Time to live in seconds - how long cached data remains valid
|
|
61
|
+
* @default 300 (5 minutes)
|
|
62
|
+
*/
|
|
63
|
+
ttl?: number;
|
|
64
|
+
/**
|
|
65
|
+
* HTTP methods to cache
|
|
66
|
+
* @default ['GET']
|
|
67
|
+
*/
|
|
68
|
+
methods?: string[];
|
|
69
|
+
/**
|
|
70
|
+
* Maximum number of cache entries to store
|
|
71
|
+
* When exceeded, oldest entries are removed (LRU)
|
|
72
|
+
* @default Infinity (no limit)
|
|
73
|
+
*/
|
|
74
|
+
maxSize?: number;
|
|
75
|
+
/**
|
|
76
|
+
* Custom cache key generator
|
|
77
|
+
* By default uses: `${method}:${path}:${JSON.stringify(input)}`
|
|
78
|
+
*
|
|
79
|
+
* @param method - HTTP method
|
|
80
|
+
* @param path - Request path
|
|
81
|
+
* @param input - Request input/body
|
|
82
|
+
* @returns Cache key string
|
|
83
|
+
*/
|
|
84
|
+
keyGenerator?: (method: string, path: string, input: any) => string;
|
|
85
|
+
/**
|
|
86
|
+
* Custom storage adapter (useful for persistent caching)
|
|
87
|
+
* @default In-memory Map
|
|
88
|
+
*/
|
|
89
|
+
storage?: CacheStorage;
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Cache storage interface
|
|
93
|
+
*/
|
|
94
|
+
interface CacheStorage {
|
|
95
|
+
get(key: string): CacheEntry | undefined;
|
|
96
|
+
set(key: string, value: CacheEntry): void;
|
|
97
|
+
delete(key: string): void;
|
|
98
|
+
clear(): void;
|
|
99
|
+
keys(): string[];
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Cache entry structure
|
|
103
|
+
*/
|
|
104
|
+
interface CacheEntry {
|
|
105
|
+
data: any;
|
|
106
|
+
cachedAt: Date;
|
|
107
|
+
expiresAt: Date;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Caching plugin for endpoint-fetcher
|
|
111
|
+
*
|
|
112
|
+
* Wraps API responses with caching metadata and methods, allowing you to:
|
|
113
|
+
* - Cache responses in memory
|
|
114
|
+
* - Check cache freshness
|
|
115
|
+
* - Manually refresh cached data
|
|
116
|
+
* - Invalidate specific cache entries
|
|
117
|
+
*
|
|
118
|
+
* @param config - Plugin configuration
|
|
119
|
+
* @returns Plugin options for endpoint-fetcher
|
|
120
|
+
*
|
|
121
|
+
* @example
|
|
122
|
+
* Basic usage:
|
|
123
|
+
* ```typescript
|
|
124
|
+
* import { createApiClient, get } from 'endpoint-fetcher';
|
|
125
|
+
* import { cache, CachingWrapper } from 'endpoint-fetcher-cache';
|
|
126
|
+
*
|
|
127
|
+
* const api = createApiClient({
|
|
128
|
+
* users: {
|
|
129
|
+
* endpoints: {
|
|
130
|
+
* // Output type must be CachingWrapper<T>
|
|
131
|
+
* getAll: get<void, CachingWrapper<User[]>>('/users'),
|
|
132
|
+
* getById: get<{ id: string }, CachingWrapper<User>>((input) => `/users/${input.id}`)
|
|
133
|
+
* }
|
|
134
|
+
* }
|
|
135
|
+
* }, {
|
|
136
|
+
* baseUrl: 'https://api.example.com',
|
|
137
|
+
* plugins: [
|
|
138
|
+
* cache({ ttl: 300 }) // Cache for 5 minutes
|
|
139
|
+
* ]
|
|
140
|
+
* });
|
|
141
|
+
*
|
|
142
|
+
* const result = await api.users.getAll();
|
|
143
|
+
* console.log(result.data); // User[]
|
|
144
|
+
* console.log(result.cachedAt); // Date
|
|
145
|
+
* console.log(result.isStale); // boolean
|
|
146
|
+
* await result.refresh(); // Force refresh
|
|
147
|
+
* result.invalidate(); // Clear from cache
|
|
148
|
+
* ```
|
|
149
|
+
*
|
|
150
|
+
* @example
|
|
151
|
+
* Advanced usage with custom storage:
|
|
152
|
+
* ```typescript
|
|
153
|
+
* import { cache } from 'endpoint-fetcher-cache';
|
|
154
|
+
*
|
|
155
|
+
* const api = createApiClient({
|
|
156
|
+
* // ... endpoints
|
|
157
|
+
* }, {
|
|
158
|
+
* baseUrl: 'https://api.example.com',
|
|
159
|
+
* plugins: [
|
|
160
|
+
* cache({
|
|
161
|
+
* ttl: 600, // 10 minutes
|
|
162
|
+
* methods: ['GET', 'POST'], // Cache GET and POST
|
|
163
|
+
* maxSize: 100, // Store max 100 entries
|
|
164
|
+
* keyGenerator: (method, path, input) => {
|
|
165
|
+
* // Custom key generation
|
|
166
|
+
* return `custom:${method}:${path}`;
|
|
167
|
+
* }
|
|
168
|
+
* })
|
|
169
|
+
* ]
|
|
170
|
+
* });
|
|
171
|
+
* ```
|
|
172
|
+
*/
|
|
173
|
+
declare const cache: (() => PluginOptions) | ((config: CachePluginConfig) => PluginOptions);
|
|
174
|
+
/**
|
|
175
|
+
* Clears all cache entries
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* ```typescript
|
|
179
|
+
* import { clearCache } from 'endpoint-fetcher-cache';
|
|
180
|
+
*
|
|
181
|
+
* clearCache(); // Clears all cached data
|
|
182
|
+
* ```
|
|
183
|
+
*/
|
|
184
|
+
declare const clearCache: (storage?: CacheStorage) => void;
|
|
185
|
+
|
|
186
|
+
export { type CacheEntry, type CachePluginConfig, type CacheStorage, type CachingWrapper, cache, clearCache, cache as default };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createPlugin } from "endpoint-fetcher";
|
|
3
|
+
var InMemoryCacheStorage = class {
|
|
4
|
+
constructor(maxSize = Infinity) {
|
|
5
|
+
this.maxSize = maxSize;
|
|
6
|
+
this.cache = /* @__PURE__ */ new Map();
|
|
7
|
+
this.accessOrder = [];
|
|
8
|
+
}
|
|
9
|
+
get(key) {
|
|
10
|
+
const entry = this.cache.get(key);
|
|
11
|
+
if (entry) {
|
|
12
|
+
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
13
|
+
this.accessOrder.push(key);
|
|
14
|
+
}
|
|
15
|
+
return entry;
|
|
16
|
+
}
|
|
17
|
+
set(key, value) {
|
|
18
|
+
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
|
|
19
|
+
const oldestKey = this.accessOrder.shift();
|
|
20
|
+
if (oldestKey) {
|
|
21
|
+
this.cache.delete(oldestKey);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
this.cache.set(key, value);
|
|
25
|
+
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
26
|
+
this.accessOrder.push(key);
|
|
27
|
+
}
|
|
28
|
+
delete(key) {
|
|
29
|
+
this.cache.delete(key);
|
|
30
|
+
this.accessOrder = this.accessOrder.filter((k) => k !== key);
|
|
31
|
+
}
|
|
32
|
+
clear() {
|
|
33
|
+
this.cache.clear();
|
|
34
|
+
this.accessOrder = [];
|
|
35
|
+
}
|
|
36
|
+
keys() {
|
|
37
|
+
return Array.from(this.cache.keys());
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var defaultKeyGenerator = (method, path, input) => {
|
|
41
|
+
const inputStr = input === void 0 || input === null ? "" : JSON.stringify(input);
|
|
42
|
+
return `${method}:${path}:${inputStr}`;
|
|
43
|
+
};
|
|
44
|
+
var cache = createPlugin((config = {}) => {
|
|
45
|
+
const {
|
|
46
|
+
ttl = 300,
|
|
47
|
+
methods = ["GET"],
|
|
48
|
+
maxSize = Infinity,
|
|
49
|
+
keyGenerator = defaultKeyGenerator,
|
|
50
|
+
storage = new InMemoryCacheStorage(maxSize)
|
|
51
|
+
} = config;
|
|
52
|
+
return {
|
|
53
|
+
handlerWrapper: (originalHandler) => {
|
|
54
|
+
return async (input, context) => {
|
|
55
|
+
if (!methods.includes(context.method)) {
|
|
56
|
+
return originalHandler(input, context);
|
|
57
|
+
}
|
|
58
|
+
const cacheKey = keyGenerator(context.method, context.path, input);
|
|
59
|
+
const createWrapper = (data, cachedAt2, expiresAt2) => {
|
|
60
|
+
const wrapper = {
|
|
61
|
+
data,
|
|
62
|
+
cachedAt: cachedAt2,
|
|
63
|
+
expiresAt: expiresAt2,
|
|
64
|
+
get isStale() {
|
|
65
|
+
return /* @__PURE__ */ new Date() > expiresAt2;
|
|
66
|
+
},
|
|
67
|
+
refresh: async () => {
|
|
68
|
+
storage.delete(cacheKey);
|
|
69
|
+
const freshData = await originalHandler(input, context);
|
|
70
|
+
const newCachedAt = /* @__PURE__ */ new Date();
|
|
71
|
+
const newExpiresAt = new Date(newCachedAt.getTime() + ttl * 1e3);
|
|
72
|
+
storage.set(cacheKey, {
|
|
73
|
+
data: freshData,
|
|
74
|
+
cachedAt: newCachedAt,
|
|
75
|
+
expiresAt: newExpiresAt
|
|
76
|
+
});
|
|
77
|
+
return createWrapper(freshData, newCachedAt, newExpiresAt);
|
|
78
|
+
},
|
|
79
|
+
invalidate: () => {
|
|
80
|
+
storage.delete(cacheKey);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
return wrapper;
|
|
84
|
+
};
|
|
85
|
+
const cached = storage.get(cacheKey);
|
|
86
|
+
const now = /* @__PURE__ */ new Date();
|
|
87
|
+
if (cached && now < cached.expiresAt) {
|
|
88
|
+
return createWrapper(cached.data, cached.cachedAt, cached.expiresAt);
|
|
89
|
+
}
|
|
90
|
+
const result = await originalHandler(input, context);
|
|
91
|
+
const cachedAt = /* @__PURE__ */ new Date();
|
|
92
|
+
const expiresAt = new Date(cachedAt.getTime() + ttl * 1e3);
|
|
93
|
+
storage.set(cacheKey, {
|
|
94
|
+
data: result,
|
|
95
|
+
cachedAt,
|
|
96
|
+
expiresAt
|
|
97
|
+
});
|
|
98
|
+
return createWrapper(result, cachedAt, expiresAt);
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
var clearCache = (storage) => {
|
|
104
|
+
if (storage) {
|
|
105
|
+
storage.clear();
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
var index_default = cache;
|
|
109
|
+
export {
|
|
110
|
+
cache,
|
|
111
|
+
clearCache,
|
|
112
|
+
index_default as default
|
|
113
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@endpoint-fetcher/cache",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Caching plugin for endpoint-fetcher with type-safe wrapper support",
|
|
6
|
+
"main": "./dist/index.cjs",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs",
|
|
13
|
+
"types": "./dist/index.d.ts"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
|
|
23
|
+
"watch": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
24
|
+
"prepublishOnly": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"endpoint-fetcher",
|
|
28
|
+
"plugin",
|
|
29
|
+
"cache",
|
|
30
|
+
"caching",
|
|
31
|
+
"api",
|
|
32
|
+
"fetch",
|
|
33
|
+
"typescript",
|
|
34
|
+
"type-safe"
|
|
35
|
+
],
|
|
36
|
+
"author": "Lorenzo Vecchio",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/lorenzo-vecchio/endpoint-fetcher-cache.git"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/lorenzo-vecchio/endpoint-fetcher-cache/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/lorenzo-vecchio/endpoint-fetcher-cache#readme",
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=16.0.0"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"endpoint-fetcher": "^2.0.0"
|
|
51
|
+
},
|
|
52
|
+
"devDependencies": {
|
|
53
|
+
"@types/node": "^20.0.0",
|
|
54
|
+
"endpoint-fetcher": "^2.1.1",
|
|
55
|
+
"tsup": "^8.5.1",
|
|
56
|
+
"typescript": "^5.0.0"
|
|
57
|
+
}
|
|
58
|
+
}
|