pulse-js-framework 1.9.3 → 1.10.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/sw/index.js ADDED
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Pulse Service Worker Utilities (SW Context)
3
+ * Cache strategy helpers for use INSIDE a service worker file.
4
+ *
5
+ * @module pulse-js-framework/sw
6
+ *
7
+ * @example
8
+ * // In your sw.js:
9
+ * import { createCacheStrategy } from 'pulse-js-framework/sw';
10
+ *
11
+ * const staticCache = createCacheStrategy('cache-first', {
12
+ * cacheName: 'static-v1',
13
+ * match: /\.(js|css|png|woff2)$/,
14
+ * });
15
+ *
16
+ * self.addEventListener('fetch', (event) => {
17
+ * staticCache.handle(event);
18
+ * });
19
+ */
20
+
21
+ // =============================================================================
22
+ // CACHE STRATEGY FACTORY
23
+ // =============================================================================
24
+
25
+ /**
26
+ * Create a cache strategy for handling fetch events
27
+ *
28
+ * @param {'cache-first'|'network-first'|'stale-while-revalidate'|'network-only'|'cache-only'} name - Strategy type
29
+ * @param {Object} options - Strategy options
30
+ * @param {string} options.cacheName - Cache storage name
31
+ * @param {RegExp|Function} [options.match] - URL matching (regex or function)
32
+ * @param {number} [options.maxAge] - Max cache age in ms
33
+ * @param {number} [options.maxEntries] - Max cached entries
34
+ * @param {number} [options.timeout] - Network timeout in ms (for network-first)
35
+ * @returns {Object} Strategy with handle() method
36
+ */
37
+ export function createCacheStrategy(name, options = {}) {
38
+ const {
39
+ cacheName,
40
+ match = null,
41
+ maxAge = 0,
42
+ maxEntries = 0,
43
+ timeout = 5000,
44
+ } = options;
45
+
46
+ if (!cacheName) {
47
+ throw new Error('createCacheStrategy: cacheName is required');
48
+ }
49
+
50
+ function _matches(request) {
51
+ const url = request.url || request;
52
+ if (!match) return true;
53
+ if (match instanceof RegExp) return match.test(url);
54
+ if (typeof match === 'function') return match(url);
55
+ return false;
56
+ }
57
+
58
+ async function _cleanupCache(cache) {
59
+ if (!maxEntries && !maxAge) return;
60
+
61
+ const keys = await cache.keys();
62
+
63
+ if (maxAge > 0) {
64
+ const now = Date.now();
65
+ for (const request of keys) {
66
+ const response = await cache.match(request);
67
+ if (response) {
68
+ const dateHeader = response.headers.get('sw-cache-time');
69
+ if (dateHeader && (now - parseInt(dateHeader, 10)) > maxAge) {
70
+ await cache.delete(request);
71
+ }
72
+ }
73
+ }
74
+ }
75
+
76
+ if (maxEntries > 0) {
77
+ const remaining = await cache.keys();
78
+ if (remaining.length > maxEntries) {
79
+ const toDelete = remaining.slice(0, remaining.length - maxEntries);
80
+ for (const request of toDelete) {
81
+ await cache.delete(request);
82
+ }
83
+ }
84
+ }
85
+ }
86
+
87
+ async function _cacheResponse(cache, request, response) {
88
+ // Clone and add cache timestamp header
89
+ const headers = new Headers(response.headers);
90
+ headers.set('sw-cache-time', String(Date.now()));
91
+
92
+ const cachedResponse = new Response(response.clone().body, {
93
+ status: response.status,
94
+ statusText: response.statusText,
95
+ headers,
96
+ });
97
+
98
+ await cache.put(request, cachedResponse);
99
+ _cleanupCache(cache).catch(() => {});
100
+ }
101
+
102
+ // Strategy implementations
103
+ const strategies = {
104
+ 'cache-first': async (request) => {
105
+ const cache = await caches.open(cacheName);
106
+ const cached = await cache.match(request);
107
+ if (cached) return cached;
108
+
109
+ const response = await fetch(request);
110
+ if (response.ok) {
111
+ await _cacheResponse(cache, request, response);
112
+ }
113
+ return response;
114
+ },
115
+
116
+ 'network-first': async (request) => {
117
+ const cache = await caches.open(cacheName);
118
+
119
+ try {
120
+ const controller = new AbortController();
121
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
122
+
123
+ const response = await fetch(request, { signal: controller.signal });
124
+ clearTimeout(timeoutId);
125
+
126
+ if (response.ok) {
127
+ await _cacheResponse(cache, request, response);
128
+ }
129
+ return response;
130
+ } catch {
131
+ const cached = await cache.match(request);
132
+ if (cached) return cached;
133
+ throw new Error(`Network request failed and no cache for: ${request.url}`);
134
+ }
135
+ },
136
+
137
+ 'stale-while-revalidate': async (request) => {
138
+ const cache = await caches.open(cacheName);
139
+ const cached = await cache.match(request);
140
+
141
+ // Revalidate in background
142
+ const fetchPromise = fetch(request).then(response => {
143
+ if (response.ok) {
144
+ _cacheResponse(cache, request, response).catch(() => {});
145
+ }
146
+ return response;
147
+ }).catch(() => null);
148
+
149
+ // Return cached immediately, or wait for network
150
+ return cached || fetchPromise;
151
+ },
152
+
153
+ 'network-only': async (request) => {
154
+ return fetch(request);
155
+ },
156
+
157
+ 'cache-only': async (request) => {
158
+ const cache = await caches.open(cacheName);
159
+ const cached = await cache.match(request);
160
+ if (cached) return cached;
161
+ throw new Error(`No cache entry for: ${request.url}`);
162
+ },
163
+ };
164
+
165
+ const strategyFn = strategies[name];
166
+ if (!strategyFn) {
167
+ throw new Error(
168
+ `Unknown cache strategy: "${name}". Use: cache-first, network-first, stale-while-revalidate, network-only, cache-only`
169
+ );
170
+ }
171
+
172
+ return {
173
+ name,
174
+ cacheName,
175
+
176
+ /**
177
+ * Handle a fetch event with this strategy
178
+ * @param {FetchEvent} event - The fetch event
179
+ * @returns {boolean} True if this strategy handled the event
180
+ */
181
+ handle(event) {
182
+ if (!_matches(event.request)) return false;
183
+
184
+ event.respondWith(strategyFn(event.request));
185
+ return true;
186
+ },
187
+
188
+ /**
189
+ * Fetch using this strategy (without FetchEvent)
190
+ * @param {Request|string} request - The request
191
+ * @returns {Promise<Response>}
192
+ */
193
+ fetch(request) {
194
+ return strategyFn(typeof request === 'string' ? new Request(request) : request);
195
+ },
196
+
197
+ /**
198
+ * Precache a list of URLs
199
+ * @param {string[]} urls - URLs to precache
200
+ * @returns {Promise<void>}
201
+ */
202
+ async precache(urls) {
203
+ const cache = await caches.open(cacheName);
204
+ await cache.addAll(urls);
205
+ },
206
+
207
+ /**
208
+ * Clear this strategy's cache
209
+ * @returns {Promise<boolean>}
210
+ */
211
+ async clearCache() {
212
+ return caches.delete(cacheName);
213
+ },
214
+ };
215
+ }
216
+
217
+ // =============================================================================
218
+ // SKIP WAITING LISTENER
219
+ // =============================================================================
220
+
221
+ /**
222
+ * Install a message listener for SKIP_WAITING messages.
223
+ * Call this in your service worker to enable skipWaiting from the main thread.
224
+ */
225
+ export function enableSkipWaiting() {
226
+ self.addEventListener('message', (event) => {
227
+ if (event.data?.type === 'SKIP_WAITING') {
228
+ self.skipWaiting();
229
+ }
230
+ });
231
+ }
232
+
233
+ // =============================================================================
234
+ // DEFAULT EXPORT
235
+ // =============================================================================
236
+
237
+ export default {
238
+ createCacheStrategy,
239
+ enableSkipWaiting,
240
+ };