swimple 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wes@goulet.dev
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,714 @@
1
+ # swimple
2
+
3
+ A simple service worker library for request caching.
4
+
5
+ ## Features
6
+
7
+ - ⚡ **Low Configuration**: Request caching with automatic invalidation by default - minimal setup required
8
+ - 🔄 **Smart Invalidation**: Automatically invalidate cache on mutations (POST/PATCH/PUT/DELETE)
9
+ - 🎛ïļ **Flexible Strategies**: Support for cache-first, network-first, and stale-while-revalidate
10
+ - ðŸŠķ **Lightweight**: Single-purpose library with no dependencies
11
+ - 🚀 **Modern**: Designed for ES module service workers (supported by all modern browsers as of January 2026)
12
+ - 🔧 **Configurable**: Automatic caching by default, with per-request control
13
+
14
+ ## Installation
15
+
16
+ ### Direct CDN Import (Recommended)
17
+
18
+ ```javascript
19
+ // sw.js
20
+ import { createHandleRequest } from "https://cdn.jsdelivr.net/npm/swimple@1.0.0/index.js";
21
+ ```
22
+
23
+ ### NPM Install
24
+
25
+ ```bash
26
+ npm install swimple
27
+ ```
28
+
29
+ ```javascript
30
+ // sw.js
31
+ import { createHandleRequest } from "swimple";
32
+ ```
33
+
34
+ ## Quick Start
35
+
36
+ > NOTE: This documentation focuses on modern browsers with module service workers support. If you aren't using module service workers, you can probably still use this with `importScripts` but I'll leave that to you to figure out.
37
+
38
+ ### 1. Register Your Service Worker
39
+
40
+ ```javascript
41
+ // in javascript in index.html (or app.js or main.js or whatever frameworks use nowadays)
42
+ try {
43
+ await navigator.serviceWorker.register("/sw.js", { type: "module" });
44
+ } catch (error) {
45
+ console.error("Error registering module service worker:", error);
46
+ }
47
+ ```
48
+
49
+ ### 2. Set Up Request Handler in Service Worker
50
+
51
+ ```javascript
52
+ // sw.js
53
+ import { createHandleRequest } from "https://cdn.jsdelivr.net/npm/swimple@1.0.0/index.js";
54
+
55
+ // create the request handler
56
+ const handleRequest = createHandleRequest({
57
+ cacheName: "api-cache-v1",
58
+ scope: ["/api/"] // this means only GET requests that start with /api/ will be cached
59
+ });
60
+
61
+ self.addEventListener("fetch", (event) => {
62
+ const response = handleRequest(event);
63
+
64
+ if (response) {
65
+ event.respondWith(response);
66
+ return;
67
+ }
68
+
69
+ // fall through to other logic or just let the request go to network by doing nothing
70
+ return;
71
+ });
72
+ ```
73
+
74
+ That's it! Your service worker will now cache GET requests that start with `/api/` using the default TTL of 300 seconds. Subsequent requests to the same path will return from the cache if the response is fresh (within the TTL). Any mutations (POST/PATCH/PUT/DELETE) will invalidate the cache. For example a PATCH request to `/api/users/123` will invalidate the cache for `/api/users/123` (ie: the details of that user) and `/api/users` (ie: the list of users that likely includes that user).
75
+
76
+ Any requests outside the scope (ie: not starting with `/api/`) won't be processed by the cache handler (it will return null).
77
+
78
+ ## Configuring the request handler
79
+
80
+ If you want to use different defaults for all requests, you can pass in a config object to the `createHandleRequest` function.
81
+
82
+ ### Example 1: Network-First Strategy
83
+
84
+ ```javascript
85
+ const handleRequest = createHandleRequest({
86
+ cacheName: "api-cache-v1",
87
+ scope: ["/api/"],
88
+ defaultStrategy: "network-first"
89
+ });
90
+ ```
91
+
92
+ With network-first, requests always try the network first (even if cached and fresh). If the network fails and you're offline, it will return a cached response (if available and within the fresh or stale TTL). The cache is updated in the background when the network succeeds. This is useful when you want the latest data from the network when online, but still work offline with cached data.
93
+
94
+ ### Example 2: Stale-While-Revalidate Strategy
95
+
96
+ ```javascript
97
+ const handleRequest = createHandleRequest({
98
+ cacheName: "api-cache-v1",
99
+ scope: ["/api/"],
100
+ defaultStrategy: "stale-while-revalidate"
101
+ });
102
+ ```
103
+
104
+ With stale-while-revalidate, requests return from cache immediately if available. If the cache is fresh (within 300 seconds), it's returned without updating. If the cache is stale (past the fresh TTL but within the stale TTL of 3600 seconds), the stale response is returned immediately and the cache is updated in the background. If the cache is too stale (past the stale TTL) or missing, it fetches from network. This provides instant responses while keeping data fresh in the background.
105
+
106
+ ### Example 3: Skip Inferring Invalidations
107
+
108
+ ```javascript
109
+ const handleRequest = createHandleRequest({
110
+ cacheName: "api-cache-v1",
111
+ scope: ["/api/"],
112
+ inferInvalidation: false
113
+ });
114
+ ```
115
+
116
+ With inferInvalidation set to false, the library will not automatically invalidate the cache on mutation requests. You will need to manually invalidate the cache on mutation requests using the `X-SW-Cache-Invalidate` header.
117
+
118
+ ```javascript
119
+ fetch("/api/users/123", {
120
+ method: "PATCH",
121
+ headers: {
122
+ "X-SW-Cache-Invalidate": "/api/users/123"
123
+ }
124
+ });
125
+ ```
126
+
127
+ If you need to invalidate multiple paths at once, you can set the `X-SW-Cache-Invalidate` header multiple times in a single request.
128
+
129
+ ```javascript
130
+ const headers = new Headers();
131
+ headers.append("X-SW-Cache-Invalidate", "/api/users");
132
+ headers.append("X-SW-Cache-Invalidate", "/api/users/123");
133
+
134
+ fetch("/api/users/123", {
135
+ method: "PATCH",
136
+ headers
137
+ });
138
+ ```
139
+
140
+ ### Example 4: Custom Fetch Function
141
+
142
+ You can provide a custom fetch function to intercept and modify requests/responses. This is useful for handling authentication errors (e.g., 401/403 responses) or adding custom headers to all requests.
143
+
144
+ ```javascript
145
+ const handleRequest = createHandleRequest({
146
+ cacheName: "api-cache-v1",
147
+ scope: ["/api/"],
148
+ customFetch: async (request) => {
149
+ const response = await fetch(request);
150
+
151
+ // Handle authentication errors
152
+ if (response.status === 401 || response.status === 403) {
153
+ // redirect to login page
154
+ // ...
155
+ }
156
+
157
+ return response;
158
+ }
159
+ });
160
+ ```
161
+
162
+ The `customFetch` function has the same signature as the native `fetch` function. It will be used for all network requests made by the cache handler.
163
+
164
+ ## Clearing the cache on logout
165
+
166
+ It can be useful to clear the cache on logout or other events. You can do this by setting the `X-SW-Cache-Clear` header on a request (any value will work - the header's presence triggers cache clearing).
167
+
168
+ ```javascript
169
+ fetch("/api/logout", {
170
+ method: "POST",
171
+ headers: {
172
+ "X-SW-Cache-Clear": "true" // Any value works - header presence triggers cache clear
173
+ }
174
+ });
175
+ ```
176
+
177
+ This will clear the entire cache for the cache name specified in the request handler configuration.
178
+
179
+ ## Automatic Cache Cleanup
180
+
181
+ The library automatically cleans up cache entries that are older than `maxCacheAgeSeconds` (defaults to 7200 seconds). This prevents unbounded cache growth.
182
+
183
+ Cleanup happens in two ways:
184
+
185
+ 1. **Reactive cleanup**: When a cached entry is accessed and found to be older than `maxCacheAgeSeconds`, it's immediately deleted.
186
+ 2. **Periodic cleanup**: Every 100 fetches, the library scans the cache and removes all entries older than `maxCacheAgeSeconds`.
187
+
188
+ If the service worker restarts (which can happen at any time), cleanup runs again on the first fetch after restart, ensuring cleanup happens even if the service worker restarts frequently.
189
+
190
+ ### Manual Cleanup
191
+
192
+ You can also manually trigger cleanup in your service worker's `activate` handler:
193
+
194
+ ```javascript
195
+ // sw.js
196
+ import { cleanupOldCacheEntries } from "https://cdn.jsdelivr.net/npm/swimple@1.0.0/index.js";
197
+
198
+ self.addEventListener("activate", (event) => {
199
+ event.waitUntil(
200
+ cleanupOldCacheEntries("api-cache-v1", 7200) // cacheName, maxAgeSeconds
201
+ );
202
+ });
203
+ ```
204
+
205
+ ## Understanding Fresh vs Stale TTL
206
+
207
+ - **Fresh TTL** (`defaultTTLSeconds`): Responses within this time are considered "fresh". For `cache-first` and `stale-while-revalidate` strategies, fresh responses are returned from cache without background network updates.
208
+
209
+ - **Stale TTL** (`defaultStaleTTLSeconds`): Responses past the fresh TTL but within the stale TTL are considered "stale". Stale responses can still be returned from cache:
210
+ - For `cache-first`: Used as a fallback when offline
211
+ - For `network-first`: Used as a fallback when offline
212
+ - For `stale-while-revalidate`: Returned immediately while updating in the background
213
+
214
+ If a response is past the stale TTL (or no stale TTL is set), it's too stale and must be fetched from the network.
215
+
216
+ ## API Reference
217
+
218
+ ### `createHandleRequest(config)`
219
+
220
+ Creates a request handler function for your service worker fetch handler.
221
+
222
+ #### Configuration Options
223
+
224
+ | Option | Type | Required | Default | Description |
225
+ | ------------------------ | ---------- | -------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
226
+ | `cacheName` | `string` | Yes | - | Name of the cache, used when calling `Cache.open(cacheName)` internally. Changing this name effectively clears the previous cache entries. |
227
+ | `scope` | `string[]` | No | `undefined` | URL prefixes to cache by default (e.g., `['/api/']`). If not set and `defaultTTLSeconds` is set, all same-origin GET requests are cached automatically. If not set and `defaultTTLSeconds` is not set (or 0), no requests are cached by default. Individual requests outside the scope can still enable caching with `X-SW-Cache-TTL-Seconds` header. |
228
+ | `defaultStrategy` | `string` | No | `'cache-first'` | Default caching strategy: `'cache-first'`, `'network-first'`, or `'stale-while-revalidate'`. |
229
+ | `defaultTTLSeconds` | `number` | No | `300` | Maximum age for fresh content. Fresh content will be returned from cache for cache-first and stale-while-revalidate strategies, and also from network-first when offline. Fresh content does not get updated from the network. Since this defaults to `300`, caching is automatic by default for GET requests matching the scope. Set to `0` or `undefined` to disable automatic caching (individual requests can still enable caching with `X-SW-Cache-TTL-Seconds` header). |
230
+ | `defaultStaleTTLSeconds` | `number` | No | `3600` | Maximum age for stale content. Stale content will be returned from cache for cache-first (when offline), network-first (when offline), and stale-while-revalidate strategies. That means responses past the fresh TTL but within stale TTL can still be returned from cache. Stale content does get updated from the network. |
231
+ | `inferInvalidation` | `boolean` | No | `true` | Automatically invalidate cache on POST/PATCH/PUT/DELETE requests. |
232
+ | `customFetch` | `function` | No | `fetch` | Custom fetch function to use for network requests. Receives a `Request` object and must return a `Promise<Response>`. Useful for handling authentication errors (401/403) or adding custom headers to all requests. |
233
+ | `maxCacheAgeSeconds` | `number` | No | `7200` | Maximum age (in seconds) before cache entries are automatically cleaned up. Entries older than this age are deleted. Defaults to 7200 seconds (2 hours, which is 2x the default stale TTL). Cache entries are cleaned up reactively (when accessed) and periodically (every 100 fetches). |
234
+
235
+ #### Returns
236
+
237
+ A request handler function `(event: FetchEvent) => Promise<Response> | null` that:
238
+
239
+ - Returns a `Promise<Response>` if the request is handled by the cache handler
240
+ - Returns `null` if the request should fall through to other handlers (e.g., when `X-SW-Cache-TTL-Seconds` is set to `0`, or when the request doesn't match the configured scope and has no TTL header)
241
+
242
+ ## HTTP Headers
243
+
244
+ Control caching behavior on a per-request basis using these headers:
245
+
246
+ ### `X-SW-Cache-Strategy`
247
+
248
+ Override the default caching strategy for a specific request.
249
+
250
+ **Values:**
251
+
252
+ - `cache-first` - Return from cache if fresh (within TTL), otherwise fetch from network immediately. Stale cache is only used when offline (network request fails). No background updates.
253
+ - `network-first` - Try network first, fall back to stale cache if offline (within stale TTL). Cache updated when network succeeds.
254
+ - `stale-while-revalidate` - Return from cache immediately (fresh = no update, stale = update in background). Fetch from network if too stale or missing.
255
+
256
+ **Example:**
257
+
258
+ ```javascript
259
+ fetch("/api/users", {
260
+ headers: {
261
+ "X-SW-Cache-Strategy": "network-first"
262
+ }
263
+ });
264
+ ```
265
+
266
+ ### `X-SW-Cache-TTL-Seconds`
267
+
268
+ Set the time-to-live (in seconds) used to validate a cached response when it's requested. The TTL is not stored with the cached response; only the timestamp of when it was cached is stored. Each request can specify a different TTL to use for validation.
269
+
270
+ **Example:**
271
+
272
+ ```javascript
273
+ fetch("/api/users", {
274
+ headers: {
275
+ "X-SW-Cache-TTL-Seconds": "600" // Cache for 10 minutes
276
+ }
277
+ });
278
+ ```
279
+
280
+ To completely opt out of caching for a specific request (the handler will return `null` and not process the request at all):
281
+
282
+ ```javascript
283
+ fetch("/api/random", {
284
+ headers: {
285
+ "X-SW-Cache-TTL-Seconds": "0" // Handler returns null
286
+ }
287
+ });
288
+ ```
289
+
290
+ **Important:** Setting TTL to `0` is a complete opt-out. The handler returns `null` immediately without checking cache, making network requests, or processing the request in any way.
291
+
292
+ ### `X-SW-Cache-Stale-TTL-Seconds`
293
+
294
+ Maximum age before a cache entry is considered too stale to use. Used by `cache-first` (when offline), `network-first` (when offline) and `stale-while-revalidate` strategies.
295
+
296
+ **How it works:**
297
+
298
+ - **Fresh** (within TTL): Return from cache without background update
299
+ - **Stale** (past TTL but within stale TTL): Return from cache and update in background (or use as offline fallback for cache-first and network-first)
300
+ - **Too stale** (past stale TTL): Must fetch from network, not returned from cache
301
+
302
+ **Example:**
303
+
304
+ ```javascript
305
+ fetch("/api/users", {
306
+ headers: {
307
+ "X-SW-Cache-Strategy": "stale-while-revalidate",
308
+ "X-SW-Cache-TTL-Seconds": "300", // Fresh for 5 minutes
309
+ "X-SW-Cache-Stale-TTL-Seconds": "3600" // Usable for 1 hour total
310
+ }
311
+ });
312
+ ```
313
+
314
+ **Timeline:**
315
+
316
+ - 0-300s: Return from cache (fresh) - no background update
317
+ - 300-3600s: Return from cache (stale) - update in background
318
+ - 3600s+: Cache too stale - fetch from network
319
+
320
+ ### `X-SW-Cache-Invalidate`
321
+
322
+ Explicitly invalidate specific cache entries. Can be set multiple times for multiple paths.
323
+
324
+ **Important:** When `X-SW-Cache-Invalidate` headers are present, they **take precedence** over automatically inferred invalidation paths. If headers are provided, only the header-specified paths are invalidated - inferred paths are not added. This allows you to have fine-grained control over invalidation even when `inferInvalidation: true`.
325
+
326
+ **Example:**
327
+
328
+ ```javascript
329
+ const headers = new Headers();
330
+ headers.append("X-SW-Cache-Invalidate", "/api/users");
331
+ headers.append("X-SW-Cache-Invalidate", "/api/users/123");
332
+ headers.append("X-SW-Cache-Invalidate", "/api/teams");
333
+
334
+ fetch("/api/users/123", {
335
+ method: "PATCH",
336
+ headers,
337
+ body: JSON.stringify(userData)
338
+ });
339
+ ```
340
+
341
+ **Example: Headers override inferred paths**
342
+
343
+ ```javascript
344
+ // PATCH /api/users/123 with inferInvalidation: true
345
+ // Without header, would invalidate: /api/users/123 AND /api/users
346
+ // With header, only invalidates: /api/users (header takes precedence)
347
+
348
+ fetch("/api/users/123", {
349
+ method: "PATCH",
350
+ headers: {
351
+ "X-SW-Cache-Invalidate": "/api/users" // Only this path is invalidated
352
+ },
353
+ body: JSON.stringify(userData)
354
+ });
355
+ ```
356
+
357
+ ### `X-SW-Cache-Clear`
358
+
359
+ Clear the entire cache. Typically used on logout to remove all user-specific cached data.
360
+
361
+ **Important:** If this header is present (regardless of its value), the cache will be cleared.
362
+
363
+ **Important behavior:**
364
+
365
+ - When this header is present, the handler **always goes to network** - it does not check cache or return cached values
366
+ - Stale responses are **never used**, even if offline
367
+ - If the network request fails (e.g., offline), the error **bubbles up** to the caller
368
+ - The cache is **always cleared** before making the network request, regardless of whether the network request succeeds or fails
369
+
370
+ **Example:**
371
+
372
+ ```javascript
373
+ fetch("/api/logout", {
374
+ method: "POST",
375
+ headers: {
376
+ "X-SW-Cache-Clear": "true" // Any value works - header presence triggers cache clear
377
+ }
378
+ });
379
+ ```
380
+
381
+ **Note:** While this header is typically used on mutation requests (POST/PATCH/PUT/DELETE), if used on a GET request, it will still clear the cache and attempt to fetch from network. If the network fails, the error will propagate to the caller.
382
+
383
+ ## Caching Behavior
384
+
385
+ ### Automatic Caching (Default)
386
+
387
+ Since `defaultTTLSeconds` defaults to `300`, caching is automatic by default. All GET requests matching the configured scope are cached automatically.
388
+
389
+ ```javascript
390
+ const handleRequest = createHandleRequest({
391
+ cacheName: "api-cache-v1",
392
+ scope: ["/api/"]
393
+ // defaultTTLSeconds defaults to 300, so all /api/* GETs cached automatically
394
+ });
395
+
396
+ // Automatically cached with default TTL of 300 seconds
397
+ fetch("/api/users");
398
+
399
+ // Completely opt out of caching for specific requests (handler returns null)
400
+ fetch("/api/random", {
401
+ headers: {
402
+ "X-SW-Cache-TTL-Seconds": "0"
403
+ }
404
+ });
405
+ ```
406
+
407
+ ### Disabling Automatic Caching
408
+
409
+ To disable automatic caching, set `defaultTTLSeconds` to `0` or `undefined`. Individual requests can still enable caching with the `X-SW-Cache-TTL-Seconds` header.
410
+
411
+ ```javascript
412
+ const handleRequest = createHandleRequest({
413
+ cacheName: "api-cache-v1",
414
+ scope: ["/api/"],
415
+ defaultTTLSeconds: 0 // Disable automatic caching
416
+ });
417
+
418
+ // Must explicitly enable caching per request
419
+ fetch("/api/users", {
420
+ headers: {
421
+ "X-SW-Cache-TTL-Seconds": "300"
422
+ }
423
+ });
424
+ ```
425
+
426
+ ## Automatic Cache Invalidation
427
+
428
+ When `inferInvalidation: true` (default), the library automatically invalidates relevant cache entries on mutation requests:
429
+
430
+ | Request | Invalidates |
431
+ | ----------------------- | ----------------------------------------- |
432
+ | `POST /api/users` | `GET /api/users` |
433
+ | `PATCH /api/users/123` | `GET /api/users/123` AND `GET /api/users` |
434
+ | `PUT /api/users/123` | `GET /api/users/123` AND `GET /api/users` |
435
+ | `DELETE /api/users/123` | `GET /api/users/123` AND `GET /api/users` |
436
+
437
+ The library strips the last path segment to find the collection endpoint. This works for most REST API patterns, but may not handle all edge cases (e.g., nested resources like `/api/users/123/avatar`). For edge cases, you can manually specify invalidation paths using the `X-SW-Cache-Invalidate` header.
438
+
439
+ **Note:** If you provide `X-SW-Cache-Invalidate` headers, they take precedence over inferred paths. Only the header-specified paths will be invalidated, not the inferred ones.
440
+
441
+ **Example: Handling nested resources**
442
+
443
+ ```javascript
444
+ // DELETE /api/users/123/avatar - inferred invalidation would only handle
445
+ // /api/users/123/avatar and /api/users/123, but we also want to invalidate
446
+ // /api/users since the user list might show avatar thumbnails
447
+ const headers = new Headers();
448
+ headers.append("X-SW-Cache-Invalidate", "/api/users/123/avatar");
449
+ headers.append("X-SW-Cache-Invalidate", "/api/users/123");
450
+ headers.append("X-SW-Cache-Invalidate", "/api/users");
451
+
452
+ fetch("/api/users/123/avatar", {
453
+ method: "DELETE",
454
+ headers
455
+ });
456
+ ```
457
+
458
+ You can disable automatic invalidation:
459
+
460
+ ```javascript
461
+ const handleRequest = createHandleRequest({
462
+ cacheName: "api-cache-v1",
463
+ scope: ["/api/"],
464
+ inferInvalidation: false // Disable automatic invalidation
465
+ });
466
+ ```
467
+
468
+ ## More Usage Examples
469
+
470
+ ### Example 1: Basic API Caching
471
+
472
+ ```javascript
473
+ // sw.js
474
+ import { createHandleRequest } from "swimple";
475
+
476
+ const handleRequest = createHandleRequest({
477
+ cacheName: "api-cache-v1",
478
+ scope: ["/api/"]
479
+ });
480
+
481
+ self.addEventListener("fetch", (event) => {
482
+ const response = handleRequest(event);
483
+ if (response) {
484
+ event.respondWith(response);
485
+ }
486
+ });
487
+
488
+ // Client code
489
+ // Cache user list for 10 minutes
490
+ fetch("/api/users", {
491
+ headers: {
492
+ "X-SW-Cache-TTL-Seconds": "600"
493
+ }
494
+ });
495
+
496
+ // Update user - automatically invalidates /api/users and /api/users/123
497
+ fetch("/api/users/123", {
498
+ method: "PATCH",
499
+ body: JSON.stringify({ name: "Jane Doe" })
500
+ });
501
+ ```
502
+
503
+ ### Example 2: Automatic Caching with Custom TTL
504
+
505
+ ```javascript
506
+ // sw.js
507
+ const handleRequest = createHandleRequest({
508
+ cacheName: "api-cache-v1",
509
+ scope: ["/api/"],
510
+ defaultStrategy: "cache-first",
511
+ defaultTTLSeconds: 600 // Cache all API calls for 10 minutes
512
+ });
513
+
514
+ self.addEventListener("fetch", (event) => {
515
+ const response = handleRequest(event);
516
+ if (response) {
517
+ event.respondWith(response);
518
+ }
519
+ });
520
+
521
+ // Client code
522
+ // Automatically cached
523
+ fetch("/api/users");
524
+
525
+ // Completely opt out of caching for specific request (handler returns null)
526
+ fetch("/api/live-data", {
527
+ headers: {
528
+ "X-SW-Cache-TTL-Seconds": "0"
529
+ }
530
+ });
531
+ ```
532
+
533
+ ### Example 3: Stale-While-Revalidate
534
+
535
+ ```javascript
536
+ // sw.js
537
+ const handleRequest = createHandleRequest({
538
+ cacheName: "api-cache-v1",
539
+ scope: ["/api/"],
540
+ defaultStrategy: "stale-while-revalidate",
541
+ defaultTTLSeconds: 300, // Fresh for 5 minutes - return from cache without background update
542
+ defaultStaleTTLSeconds: 3600 // Stale for up to 1 hour - return from cache and update in background
543
+ });
544
+
545
+ self.addEventListener("fetch", (event) => {
546
+ const response = handleRequest(event);
547
+ if (response) {
548
+ event.respondWith(response);
549
+ }
550
+ });
551
+
552
+ // Client code
553
+ // Returns cached data immediately (fresh = no update, stale = update in background)
554
+ fetch("/api/users");
555
+ ```
556
+
557
+ ### Example 4: Multiple Scopes
558
+
559
+ ```javascript
560
+ // sw.js
561
+ const handleRequest = createHandleRequest({
562
+ cacheName: "api-cache-v1",
563
+ scope: ["/api/", "/graphql/", "/data/"],
564
+ defaultTTLSeconds: 300
565
+ });
566
+
567
+ self.addEventListener("fetch", (event) => {
568
+ const response = handleRequest(event);
569
+ if (response) {
570
+ event.respondWith(response);
571
+ }
572
+ });
573
+ ```
574
+
575
+ ### Example 5: Logout with Cache Clear
576
+
577
+ ```javascript
578
+ // Client code
579
+ async function logout() {
580
+ await fetch("/api/logout", {
581
+ method: "POST",
582
+ headers: {
583
+ "X-SW-Cache-Clear": "true" // Clear all cached user data
584
+ }
585
+ });
586
+
587
+ window.location.href = "/login";
588
+ }
589
+ ```
590
+
591
+ ### Example 6: Custom Request Handler Chain
592
+
593
+ Your service worker might have other "handlers" or "middlewares" that need to be called before the cache handler.
594
+
595
+ ```javascript
596
+ // sw.js
597
+ import { createHandleRequest } from "swimple";
598
+
599
+ const handleRequest = createHandleRequest({
600
+ cacheName: "api-cache-v1",
601
+ scope: ["/api/"],
602
+ defaultTTLSeconds: 300
603
+ });
604
+
605
+ // Custom handler for special handling
606
+ const customHandler = (event) => {
607
+ const url = new URL(event.request.url);
608
+
609
+ // Special handling for auth endpoints
610
+ if (url.pathname.startsWith("/api/auth")) {
611
+ return fetch(event.request);
612
+ }
613
+
614
+ return null; // Fall through
615
+ };
616
+
617
+ self.addEventListener("fetch", (event) => {
618
+ // Try custom handler first
619
+ let response = customHandler(event);
620
+ if (response) {
621
+ event.respondWith(response);
622
+ return;
623
+ }
624
+
625
+ // Then try cache handler
626
+ response = handleRequest(event);
627
+ if (response) {
628
+ event.respondWith(response);
629
+ return;
630
+ }
631
+
632
+ // Default fetch
633
+ event.respondWith(fetch(event.request));
634
+ });
635
+ ```
636
+
637
+ ## How It Works
638
+
639
+ 1. **GET Requests**: The request handler checks if a GET request matches the configured scope and caching criteria
640
+ 2. **Cache Lookup**: For eligible requests, it checks the Cache API for a valid cached response
641
+ 3. **Strategy Execution**: Based on the strategy (cache-first, network-first, stale-while-revalidate), it either returns cached data or fetches from network
642
+ 4. **TTL Management**: Cached responses store only the timestamp of when they were cached. When a request is made, the TTL from the request (or default) is used to calculate if the cached response is fresh or stale by comparing the current time with the cached timestamp. Responses within the TTL are "fresh" (returned without background updates). Responses past the TTL but within the stale TTL are "stale" (returned with background updates or used as offline fallback)
643
+ 5. **Mutation Handling**: POST/PATCH/PUT/DELETE requests trigger cache invalidation based on inferred or explicit paths
644
+ 6. **Cache Clearing**: Requests with `X-SW-Cache-Clear` header wipe the entire cache
645
+
646
+ ## Important Notes
647
+
648
+ - Only GET requests are cached
649
+ - Only 2xx (OK) GET responses are cached. Non-OK responses (4xx, 5xx, etc.) are not cached
650
+ - Non-GET and non-mutating requests (POST/PATCH/PUT/DELETE) are not processed by the cache handler - it will return null. Practically, this means HEAD requests are not handled by the cache handler.
651
+ - Query strings are part of the cache key. Different query strings create different cache entries (e.g., `/api/users?page=1` and `/api/users?page=2` are separate cache entries)
652
+ - Cache invalidation happens automatically for mutations when `inferInvalidation: true`
653
+ - All headers are case-insensitive (per HTTP spec)
654
+ - TTL of `0` completely opts out of caching for a request - the handler returns `null` immediately without checking cache, making network requests, or processing the request.
655
+ - Cache entries store only the timestamp of when they were cached. The TTL is not stored; it's provided by each request (or set with a default via `createHandleRequest` config) and the freshness or staleness is calculated at request time. This means one request could use a longer TTL than another request and therefore allow a later expiration time.
656
+
657
+ ## Error Handling
658
+
659
+ The library follows a straightforward error handling approach:
660
+
661
+ ### Configuration Validation Errors
662
+
663
+ Invalid configuration values passed to `createHandleRequest` will throw errors immediately. This helps catch configuration mistakes early.
664
+
665
+ ```javascript
666
+ // This will throw an error
667
+ const handleRequest = createHandleRequest({
668
+ cacheName: "api-cache-v1",
669
+ defaultStrategy: "invalid-strategy" // Error: invalid strategy
670
+ });
671
+ ```
672
+
673
+ ### Exceptional Errors
674
+
675
+ The library does not catch or swallow exceptional errors. If an internal operation like `cache.delete()` throws an exception (which is truly exceptional since browsers don't throw in normal cases), that error will bubble up to your code.
676
+
677
+ This means you can wrap your `handleRequest` calls in try/catch if you want to handle errors:
678
+
679
+ ```javascript
680
+ self.addEventListener("fetch", (event) => {
681
+ try {
682
+ const response = handleRequest(event);
683
+ if (response) {
684
+ event.respondWith(response);
685
+ }
686
+ } catch (error) {
687
+ // Handle exceptional errors
688
+ console.error("Cache handler error:", error);
689
+ // Fall back to network
690
+ event.respondWith(fetch(event.request));
691
+ }
692
+ });
693
+ ```
694
+
695
+ If you don't wrap `handleRequest` in try/catch, any exceptional errors will propagate normally, which may cause the service worker fetch handler to fail. Whether you need error handling depends on your application's requirements.
696
+
697
+ ## License
698
+
699
+ MIT
700
+
701
+ ## Contributing
702
+
703
+ Contributions are welcome! Please open an issue or PR on GitHub.
704
+
705
+ ## Links
706
+
707
+ - [GitHub Repository](#)
708
+ - [NPM Package](#)
709
+ - [Documentation](#)
710
+ - [Report Issues](#)
711
+
712
+ ## TODO
713
+
714
+ - [ ] make a landing page (at https://swimple.goulet.dev) with example usage and a link to the documentation
package/helpers.js ADDED
@@ -0,0 +1,304 @@
1
+ // @ts-check
2
+
3
+ // This library is meant to be called from service workers, so we include
4
+ // webworker types and exclude DOM types (which don't exist in service workers)
5
+ /// <reference no-default-lib="true"/>
6
+ /// <reference lib="esnext" />
7
+ /// <reference lib="webworker" />
8
+
9
+ /** @import { CacheStrategy, HandleRequestConfig } from "./types" */
10
+
11
+ /**
12
+ * Get header value (case-insensitive)
13
+ * @param {Headers} headers
14
+ * @param {string} name
15
+ * @returns {string | null}
16
+ */
17
+ export function getHeader(headers, name) {
18
+ const lowerName = name.toLowerCase();
19
+ // Headers is iterable in browsers, iterate through entries
20
+ const entries = /** @type {unknown} */ (headers);
21
+ const iterable = /** @type {Iterable<[string, string]>} */ (entries);
22
+ for (const [key, value] of iterable) {
23
+ if (key.toLowerCase() === lowerName) {
24
+ return value;
25
+ }
26
+ }
27
+ return null;
28
+ }
29
+
30
+ /**
31
+ * Get all header values for a given header name (case-insensitive)
32
+ * Useful when a header has been set multiple times (e.g., multiple X-SW-Cache-Invalidate headers)
33
+ * When headers are set multiple times with append(), they are concatenated with ", " (comma+space)
34
+ * This function splits them back into individual values
35
+ * @param {Headers} headers
36
+ * @param {string} name - Header name to look up
37
+ * @returns {string[]} Array of all header values for the given name
38
+ */
39
+ export function getAllHeaders(headers, name) {
40
+ const lowerName = name.toLowerCase();
41
+ const values = [];
42
+ // Headers is iterable in browsers, iterate through entries
43
+ const entries = /** @type {unknown} */ (headers);
44
+ const iterable = /** @type {Iterable<[string, string]>} */ (entries);
45
+ for (const [key, value] of iterable) {
46
+ if (key.toLowerCase() === lowerName) {
47
+ // Split comma+space separated values (HTTP spec: multiple header values are comma-separated)
48
+ // Split on ", " (comma+space) to handle concatenated values from multiple append() calls
49
+ const splitValues = value.split(", ");
50
+ values.push(...splitValues);
51
+ }
52
+ }
53
+ return values;
54
+ }
55
+
56
+ /**
57
+ * Get cache timestamp from response
58
+ * @param {Response} response
59
+ * @returns {number | null} Timestamp in milliseconds since epoch, or null if not found
60
+ */
61
+ export function getCacheTimestamp(response) {
62
+ const timestampHeader = getHeader(response.headers, "x-sw-cache-timestamp");
63
+ if (!timestampHeader) {
64
+ return null;
65
+ }
66
+ const timestamp = parseInt(timestampHeader, 10);
67
+ return isNaN(timestamp) ? null : timestamp;
68
+ }
69
+
70
+ /**
71
+ * Check if response is fresh
72
+ * @param {Response} response
73
+ * @param {number} ttl - Time-to-live in seconds
74
+ * @returns {boolean}
75
+ */
76
+ export function isFresh(response, ttl) {
77
+ const timestamp = getCacheTimestamp(response);
78
+ if (timestamp === null) {
79
+ return false;
80
+ }
81
+ const age = Date.now() - timestamp;
82
+ return age < ttl * 1000;
83
+ }
84
+
85
+ /**
86
+ * Check if response is stale (but usable)
87
+ * @param {Response} response
88
+ * @param {number} ttl - Time-to-live in seconds
89
+ * @param {number | null} staleTTL - Stale time-to-live in seconds
90
+ * @returns {boolean}
91
+ */
92
+ export function isStale(response, ttl, staleTTL) {
93
+ if (staleTTL === null) {
94
+ return false;
95
+ }
96
+ const timestamp = getCacheTimestamp(response);
97
+ if (timestamp === null) {
98
+ return false;
99
+ }
100
+ const age = Date.now() - timestamp;
101
+ return age >= ttl * 1000 && age < staleTTL * 1000;
102
+ }
103
+
104
+ /**
105
+ * Add timestamp to response
106
+ * @param {Response} response
107
+ * @returns {Response}
108
+ */
109
+ export function addTimestamp(response) {
110
+ const headers = new Headers(response.headers);
111
+ headers.set("x-sw-cache-timestamp", Date.now().toString());
112
+ return new Response(response.body, {
113
+ status: response.status,
114
+ statusText: response.statusText,
115
+ headers: headers
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Get inferred invalidation paths
121
+ * @param {string} url
122
+ * @returns {string[]}
123
+ */
124
+ export function getInferredInvalidationPaths(url) {
125
+ const urlObj = new URL(url);
126
+ const path = urlObj.pathname;
127
+ const paths = [url]; // Exact path
128
+
129
+ // Strip last path segment to get parent collection
130
+ const lastSlash = path.lastIndexOf("/");
131
+ if (lastSlash > 0) {
132
+ const parentPath = path.substring(0, lastSlash);
133
+ if (parentPath) {
134
+ const parentUrl = new URL(parentPath, urlObj.origin);
135
+ paths.push(parentUrl.toString());
136
+ }
137
+ }
138
+
139
+ return paths;
140
+ }
141
+
142
+ /**
143
+ * Get strategy from request headers or use default
144
+ * @param {Headers} headers
145
+ * @param {CacheStrategy} defaultStrategy
146
+ * @returns {CacheStrategy}
147
+ */
148
+ export function getStrategy(headers, defaultStrategy) {
149
+ const strategyHeader = getHeader(headers, "X-SW-Cache-Strategy");
150
+ if (
151
+ strategyHeader &&
152
+ ["cache-first", "network-first", "stale-while-revalidate"].includes(
153
+ strategyHeader
154
+ )
155
+ ) {
156
+ return /** @type {CacheStrategy} */ (strategyHeader);
157
+ }
158
+ return defaultStrategy;
159
+ }
160
+
161
+ /**
162
+ * Get TTL from request headers or use default
163
+ * @param {Headers} headers
164
+ * @param {number} defaultTTL - Default TTL in seconds
165
+ * @returns {number | null} TTL in seconds, or null if caching is disabled
166
+ */
167
+ export function getTTL(headers, defaultTTL) {
168
+ const ttlHeader = getHeader(headers, "X-SW-Cache-TTL-Seconds");
169
+ if (ttlHeader === null) {
170
+ return defaultTTL > 0 ? defaultTTL : null;
171
+ }
172
+ const ttl = parseInt(ttlHeader, 10);
173
+ if (isNaN(ttl) || ttl <= 0) {
174
+ return null;
175
+ }
176
+ return ttl;
177
+ }
178
+
179
+ /**
180
+ * Get stale TTL from request headers or use default
181
+ * @param {Headers} headers
182
+ * @param {number} defaultStaleTTL - Default stale TTL in seconds
183
+ * @returns {number | null} Stale TTL in seconds, or null if stale caching is disabled
184
+ */
185
+ export function getStaleTTL(headers, defaultStaleTTL) {
186
+ const staleTTLHeader = getHeader(headers, "X-SW-Cache-Stale-TTL-Seconds");
187
+ if (staleTTLHeader === null) {
188
+ return defaultStaleTTL > 0 ? defaultStaleTTL : null;
189
+ }
190
+ const staleTTL = parseInt(staleTTLHeader, 10);
191
+ if (isNaN(staleTTL) || staleTTL <= 0) {
192
+ return null;
193
+ }
194
+ return staleTTL;
195
+ }
196
+
197
+ /**
198
+ * Check if URL matches scope. Returns true if scope array is empty or if the URL pathname starts with any of the scope prefixes.
199
+ * @param {string} url
200
+ * @param {string[]} scope
201
+ * @param {number} defaultTTLSeconds
202
+ * @returns {boolean}
203
+ */
204
+ export function matchesScope(url, scope, defaultTTLSeconds) {
205
+ if (scope.length === 0) {
206
+ return defaultTTLSeconds > 0;
207
+ }
208
+ const urlObj = new URL(url);
209
+ return scope.some((prefix) => urlObj.pathname.startsWith(prefix));
210
+ }
211
+
212
+ /**
213
+ * Invalidate cache entries
214
+ * @param {string} cacheName
215
+ * @param {string[]} urls
216
+ * @returns {Promise<void>}
217
+ */
218
+ export async function invalidateCache(cacheName, urls) {
219
+ const cache = await caches.open(cacheName);
220
+ await Promise.allSettled(urls.map((url) => cache.delete(url)));
221
+ }
222
+
223
+ /**
224
+ * Clear entire cache
225
+ * @param {string} cacheName
226
+ * @returns {Promise<void>}
227
+ */
228
+ export async function clearCache(cacheName) {
229
+ const cache = await caches.open(cacheName);
230
+ const keys = await cache.keys();
231
+ await Promise.allSettled(keys.map((request) => cache.delete(request)));
232
+ }
233
+
234
+ /**
235
+ * Check if a cached response is older than the maximum age
236
+ * @param {Response} response
237
+ * @param {number} maxAgeSeconds - Maximum age in seconds
238
+ * @returns {boolean}
239
+ */
240
+ export function isOlderThanMaxAge(response, maxAgeSeconds) {
241
+ const timestamp = getCacheTimestamp(response);
242
+ if (timestamp === null) {
243
+ return false;
244
+ }
245
+ const age = Date.now() - timestamp;
246
+ return age >= maxAgeSeconds * 1000;
247
+ }
248
+
249
+ /**
250
+ * Clean up cache entries older than maxAgeSeconds
251
+ * @param {string} cacheName
252
+ * @param {number} maxAgeSeconds - Maximum age in seconds
253
+ * @returns {Promise<void>}
254
+ */
255
+ export async function cleanupOldCacheEntries(cacheName, maxAgeSeconds) {
256
+ const cache = await caches.open(cacheName);
257
+ const keys = await cache.keys();
258
+ const cleanupPromises = [];
259
+
260
+ for (const request of keys) {
261
+ const response = await cache.match(request);
262
+ if (response && isOlderThanMaxAge(response, maxAgeSeconds)) {
263
+ cleanupPromises.push(cache.delete(request));
264
+ }
265
+ }
266
+
267
+ await Promise.allSettled(cleanupPromises);
268
+ }
269
+
270
+ /**
271
+ * Validate configuration object
272
+ * @param {HandleRequestConfig} config - Configuration object to validate
273
+ * @throws {Error} If config is invalid
274
+ */
275
+ export function validateConfig(config) {
276
+ if (!config || typeof config !== "object") {
277
+ throw new Error("config is required and must be an object");
278
+ }
279
+ const cfg = config;
280
+ if (!cfg.cacheName || typeof cfg.cacheName !== "string") {
281
+ throw new Error("config.cacheName is required and must be a string");
282
+ }
283
+ if (
284
+ cfg.defaultStrategy &&
285
+ !["cache-first", "network-first", "stale-while-revalidate"].includes(
286
+ String(cfg.defaultStrategy)
287
+ )
288
+ ) {
289
+ throw new Error(
290
+ "config.defaultStrategy must be one of: 'cache-first', 'network-first', 'stale-while-revalidate'"
291
+ );
292
+ }
293
+ if (cfg.customFetch !== undefined && typeof cfg.customFetch !== "function") {
294
+ throw new Error("config.customFetch must be a function");
295
+ }
296
+ if (
297
+ cfg.maxCacheAgeSeconds !== undefined &&
298
+ (typeof cfg.maxCacheAgeSeconds !== "number" || cfg.maxCacheAgeSeconds <= 0)
299
+ ) {
300
+ throw new Error(
301
+ "config.maxCacheAgeSeconds must be a positive number if provided"
302
+ );
303
+ }
304
+ }
package/index.js ADDED
@@ -0,0 +1,256 @@
1
+ // @ts-check
2
+
3
+ // This library is meant to be called from service workers, so we include
4
+ // webworker types and exclude DOM types (which don't exist in service workers)
5
+ /// <reference no-default-lib="true"/>
6
+ /// <reference lib="esnext" />
7
+ /// <reference lib="webworker" />
8
+
9
+ /** @import { HandleRequestConfig, CacheStrategy } from "./types" */
10
+
11
+ import {
12
+ getHeader,
13
+ getAllHeaders,
14
+ isFresh,
15
+ isStale,
16
+ addTimestamp,
17
+ getInferredInvalidationPaths,
18
+ getStrategy,
19
+ getTTL,
20
+ getStaleTTL,
21
+ matchesScope,
22
+ invalidateCache,
23
+ clearCache,
24
+ validateConfig,
25
+ isOlderThanMaxAge,
26
+ cleanupOldCacheEntries
27
+ } from "./helpers.js";
28
+
29
+ /**
30
+ * Creates a request handler function for service worker fetch events.
31
+ *
32
+ * @param {HandleRequestConfig} config - Configuration options
33
+ * @returns {(event: FetchEvent) => Promise<Response> | null} Request handler function
34
+ */
35
+ export function createHandleRequest(config) {
36
+ validateConfig(config);
37
+
38
+ // Set defaults
39
+ const cacheName = config.cacheName;
40
+ const scope = config.scope || [];
41
+ const defaultStrategy = /** @type {CacheStrategy} */ (
42
+ config.defaultStrategy || "cache-first"
43
+ );
44
+ const defaultTTLSeconds = config.defaultTTLSeconds ?? 300;
45
+ const defaultStaleTTLSeconds = config.defaultStaleTTLSeconds ?? 3600;
46
+ const maxCacheAgeSeconds = config.maxCacheAgeSeconds ?? 7200;
47
+ const inferInvalidation = config.inferInvalidation ?? true;
48
+ const customFetch = config.customFetch || fetch;
49
+
50
+ // Track fetch counter for periodic cleanup
51
+ let fetchCounter = 0;
52
+
53
+ // Main request handler
54
+ return function handleRequest(event) {
55
+ const request = event.request;
56
+ const url = request.url;
57
+ const method = request.method;
58
+ const headers = request.headers;
59
+
60
+ // Handle cache clearing - header presence (any value) triggers cache clear
61
+ if (getHeader(headers, "X-SW-Cache-Clear") !== null) {
62
+ return (async () => {
63
+ await clearCache(cacheName);
64
+ return customFetch(request);
65
+ })();
66
+ }
67
+
68
+ // Handle invalidation for mutations
69
+ // Check for explicit invalidation headers first (works even if inferInvalidation is false)
70
+ const invalidateHeaders = getAllHeaders(headers, "X-SW-Cache-Invalidate");
71
+ const isMutation = ["POST", "PATCH", "PUT", "DELETE"].includes(method);
72
+
73
+ if (invalidateHeaders.length > 0 || (inferInvalidation && isMutation)) {
74
+ return (async () => {
75
+ let pathsToInvalidate = [...invalidateHeaders];
76
+
77
+ // Only add inferred paths if no explicit headers were provided
78
+ // Headers take precedence over inferred paths
79
+ if (pathsToInvalidate.length === 0 && inferInvalidation && isMutation) {
80
+ pathsToInvalidate.push(...getInferredInvalidationPaths(url));
81
+ }
82
+
83
+ if (pathsToInvalidate.length > 0) {
84
+ await invalidateCache(cacheName, pathsToInvalidate);
85
+ }
86
+
87
+ return customFetch(request);
88
+ })();
89
+ }
90
+
91
+ // Only handle GET requests
92
+ if (method !== "GET") {
93
+ return null;
94
+ }
95
+
96
+ // Periodic cleanup: run on first fetch and every 100 fetches
97
+ fetchCounter++;
98
+ if (fetchCounter === 1 || fetchCounter % 100 === 0) {
99
+ // Run cleanup asynchronously, don't block the fetch
100
+ cleanupOldCacheEntries(cacheName, maxCacheAgeSeconds).catch(() => {
101
+ // Ignore cleanup errors
102
+ });
103
+ if (fetchCounter % 100 === 0) {
104
+ fetchCounter = 1; // Reset to 1 after cleanup, not 0
105
+ }
106
+ }
107
+
108
+ // Check if request matches scope and should be cached
109
+ const hasExplicitTTLHeader =
110
+ getHeader(headers, "X-SW-Cache-TTL-Seconds") !== null;
111
+ const ttl = getTTL(headers, defaultTTLSeconds);
112
+
113
+ // If scope doesn't match and there's no explicit TTL header, don't handle the request
114
+ if (!matchesScope(url, scope, defaultTTLSeconds) && !hasExplicitTTLHeader) {
115
+ return null;
116
+ }
117
+
118
+ // If TTL is 0 or null, don't cache
119
+ if (ttl === null || ttl === 0) {
120
+ return null;
121
+ }
122
+
123
+ const staleTTL = getStaleTTL(headers, defaultStaleTTLSeconds);
124
+ const strategy = getStrategy(headers, defaultStrategy);
125
+
126
+ // Handle cache-first strategy
127
+ if (strategy === "cache-first") {
128
+ return (async () => {
129
+ const cache = await caches.open(cacheName);
130
+ const cachedResponse = await cache.match(request);
131
+
132
+ if (cachedResponse) {
133
+ if (isFresh(cachedResponse, ttl)) {
134
+ return cachedResponse;
135
+ }
136
+
137
+ // Reactive cleanup: delete if older than maxCacheAgeSeconds
138
+ if (isOlderThanMaxAge(cachedResponse, maxCacheAgeSeconds)) {
139
+ await cache.delete(request); // Fire-and-forget cleanup
140
+ }
141
+ }
142
+
143
+ // No fresh cache, fetch from network
144
+ let networkResponse;
145
+ try {
146
+ networkResponse = await customFetch(request);
147
+ } catch (error) {
148
+ // Network failed, return stale cache if available
149
+ if (cachedResponse && isStale(cachedResponse, ttl, staleTTL)) {
150
+ return cachedResponse;
151
+ }
152
+ throw error;
153
+ }
154
+
155
+ // Cache the response if successful
156
+ if (networkResponse.ok) {
157
+ const responseToCache = addTimestamp(networkResponse.clone());
158
+ await cache.put(request, responseToCache);
159
+ }
160
+ return networkResponse;
161
+ })();
162
+ }
163
+
164
+ // Handle network-first strategy
165
+ if (strategy === "network-first") {
166
+ return (async () => {
167
+ const cache = await caches.open(cacheName);
168
+
169
+ let networkResponse;
170
+ try {
171
+ networkResponse = await customFetch(request);
172
+ } catch (error) {
173
+ // Network failed, try cache
174
+ const cachedResponse = await cache.match(request);
175
+ if (cachedResponse) {
176
+ // Reactive cleanup: delete if older than maxCacheAgeSeconds
177
+ if (isOlderThanMaxAge(cachedResponse, maxCacheAgeSeconds)) {
178
+ cache.delete(request); // Fire-and-forget cleanup
179
+ throw error;
180
+ }
181
+ if (
182
+ isFresh(cachedResponse, ttl) ||
183
+ isStale(cachedResponse, ttl, staleTTL)
184
+ ) {
185
+ return cachedResponse;
186
+ }
187
+ }
188
+ throw error;
189
+ }
190
+
191
+ // Cache the response if successful
192
+ if (networkResponse.ok) {
193
+ const responseToCache = addTimestamp(networkResponse.clone());
194
+ await cache.put(request, responseToCache);
195
+ }
196
+ return networkResponse;
197
+ })();
198
+ }
199
+
200
+ // Handle stale-while-revalidate strategy
201
+ if (strategy === "stale-while-revalidate") {
202
+ return (async () => {
203
+ const cache = await caches.open(cacheName);
204
+ const cachedResponse = await cache.match(request);
205
+
206
+ if (cachedResponse) {
207
+ // Reactive cleanup: delete if older than maxCacheAgeSeconds
208
+ if (isOlderThanMaxAge(cachedResponse, maxCacheAgeSeconds)) {
209
+ cache.delete(request); // Fire-and-forget cleanup
210
+ // Continue to fetch from network
211
+ } else {
212
+ const fresh = isFresh(cachedResponse, ttl);
213
+ const stale = isStale(cachedResponse, ttl, staleTTL);
214
+
215
+ if (fresh || stale) {
216
+ // Return cached response immediately
217
+ // Update cache in background if stale
218
+ if (stale) {
219
+ customFetch(request)
220
+ .then((networkResponse) => {
221
+ if (networkResponse.ok) {
222
+ const responseToCache = addTimestamp(
223
+ networkResponse.clone()
224
+ );
225
+ cache.put(request, responseToCache);
226
+ }
227
+ })
228
+ .catch(() => {
229
+ // Ignore background update errors
230
+ });
231
+ }
232
+ return cachedResponse;
233
+ }
234
+ }
235
+ }
236
+
237
+ // No cache or too stale, fetch from network, no need for fallback if offline
238
+ // because we already know if there was a cached response it won't be
239
+ // fresh or stale if we've reached this point
240
+ const networkResponse = await customFetch(request);
241
+
242
+ // Cache the response if successful
243
+ if (networkResponse.ok) {
244
+ const responseToCache = addTimestamp(networkResponse.clone());
245
+ await cache.put(request, responseToCache);
246
+ }
247
+ return networkResponse;
248
+ })();
249
+ }
250
+
251
+ return null;
252
+ };
253
+ }
254
+
255
+ // Export cleanup function for manual use
256
+ export { cleanupOldCacheEntries } from "./helpers.js";
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "swimple",
3
+ "version": "0.1.0",
4
+ "description": "A simple service worker library for request caching",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js"
9
+ },
10
+ "scripts": {
11
+ "test": "npm run test:unit && npm run test:e2e && npm run test:ui",
12
+ "test:unit": "node --test tests/**/*.unit.test.js",
13
+ "test:e2e": "node --test tests/**/*.e2e.test.js",
14
+ "test:ui": "playwright test",
15
+ "format:check": "prettier --check .",
16
+ "format:fix": "prettier --write ."
17
+ },
18
+ "keywords": [
19
+ "service-worker",
20
+ "cache",
21
+ "caching",
22
+ "sw",
23
+ "offline",
24
+ "pwa"
25
+ ],
26
+ "author": "wes@goulet.dev",
27
+ "license": "MIT",
28
+ "devDependencies": {
29
+ "@playwright/test": "^1.57.0",
30
+ "prettier": "^3.7.4"
31
+ },
32
+ "engines": {
33
+ "node": ">=24"
34
+ },
35
+ "volta": {
36
+ "node": "24.12.0"
37
+ }
38
+ }
package/types.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Caching strategy for handling requests.
3
+ * - `cache-first`: Return from cache if fresh (within TTL), otherwise fetch from network immediately. Stale cache is only used when offline (network request fails). No background updates.
4
+ * - `network-first`: Try network first, fall back to stale cache if offline (within stale TTL). Cache updated when network succeeds.
5
+ * - `stale-while-revalidate`: Return from cache immediately (fresh = no update, stale = update in background). Fetch from network if too stale or missing.
6
+ */
7
+ export type CacheStrategy =
8
+ | "cache-first"
9
+ | "network-first"
10
+ | "stale-while-revalidate";
11
+
12
+ export interface HandleRequestConfig {
13
+ /** Name of the cache, used when calling `Cache.open(cacheName)` internally. Changing this name effectively clears the previous cache entries. */
14
+ cacheName: string;
15
+ /** URL prefixes to cache by default (e.g., `['/api/']`). If not set and `defaultTTLSeconds` is set, all same-origin GET requests are cached automatically. If not set and `defaultTTLSeconds` is not set (or 0), no requests are cached by default. Individual requests outside the scope can still enable caching with `X-SW-Cache-TTL-Seconds` header. */
16
+ scope?: string[];
17
+ /** Default caching strategy: `'cache-first'`, `'network-first'`, or `'stale-while-revalidate'`. */
18
+ defaultStrategy?: CacheStrategy;
19
+ /** Maximum age for fresh content. Fresh content will be returned from cache for cache-first and stale-while-revalidate strategies, and also from network-first when offline. Fresh content does not get updated from the network. Since this defaults to `300`, caching is automatic by default for GET requests matching the scope. Set to `0` or `undefined` to disable automatic caching (individual requests can still enable caching with `X-SW-Cache-TTL-Seconds` header). */
20
+ defaultTTLSeconds?: number;
21
+ /** Maximum age for stale content. Stale content will be returned from cache for cache-first (when offline), network-first (when offline), and stale-while-revalidate strategies. That means responses past the fresh TTL but within stale TTL can still be returned from cache. Stale content does get updated from the network. */
22
+ defaultStaleTTLSeconds?: number;
23
+ /** Automatically invalidate cache on POST/PATCH/PUT/DELETE requests. */
24
+ inferInvalidation?: boolean;
25
+ /** Custom fetch function to use for network requests. Receives a `Request` object and must return a `Promise<Response>`. Useful for handling authentication errors (401/403) or adding custom headers to all requests. */
26
+ customFetch?: typeof fetch;
27
+ /** Maximum age (in seconds) before cache entries are automatically cleaned up. Entries older than this age are deleted. Defaults to 7200 seconds (2 hours, which is 2x the default stale TTL). Cache entries are cleaned up reactively (when accessed) and periodically (every 100 fetches). */
28
+ maxCacheAgeSeconds?: number;
29
+ }