@swoff/cli 0.3.8 → 0.3.9

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.
Files changed (112) hide show
  1. package/README.md +47 -33
  2. package/dist/__tests__/assemble-sw.test.js +8 -38
  3. package/dist/__tests__/assemble-sw.test.js.map +1 -1
  4. package/dist/__tests__/cli-integration.test.js +119 -11
  5. package/dist/__tests__/cli-integration.test.js.map +1 -1
  6. package/dist/__tests__/config-types.test.js +32 -15
  7. package/dist/__tests__/config-types.test.js.map +1 -1
  8. package/dist/__tests__/file-generators.test.js +2 -2
  9. package/dist/__tests__/file-generators.test.js.map +1 -1
  10. package/dist/__tests__/glob-matcher.test.js +86 -0
  11. package/dist/__tests__/glob-matcher.test.js.map +1 -0
  12. package/dist/__tests__/invalidation-tags.test.js +207 -23
  13. package/dist/__tests__/invalidation-tags.test.js.map +1 -1
  14. package/dist/__tests__/loader.test.js +12 -12
  15. package/dist/__tests__/loader.test.js.map +1 -1
  16. package/dist/__tests__/validator.test.js +144 -97
  17. package/dist/__tests__/validator.test.js.map +1 -1
  18. package/dist/index.js +11 -10
  19. package/dist/index.js.map +1 -1
  20. package/dist/lib/cli/help.js +0 -5
  21. package/dist/lib/cli/help.js.map +1 -1
  22. package/dist/lib/commands/add.js +50 -32
  23. package/dist/lib/commands/add.js.map +1 -1
  24. package/dist/lib/commands/clean.js +1 -15
  25. package/dist/lib/commands/clean.js.map +1 -1
  26. package/dist/lib/commands/generate-guide.js +2 -2
  27. package/dist/lib/commands/generate-guide.js.map +1 -1
  28. package/dist/lib/commands/generate.js +7 -21
  29. package/dist/lib/commands/generate.js.map +1 -1
  30. package/dist/lib/commands/info.js +8 -6
  31. package/dist/lib/commands/info.js.map +1 -1
  32. package/dist/lib/commands/init.js +2 -10
  33. package/dist/lib/commands/init.js.map +1 -1
  34. package/dist/lib/commands/validate.js +3 -3
  35. package/dist/lib/commands/validate.js.map +1 -1
  36. package/dist/lib/config/loader.js +3 -6
  37. package/dist/lib/config/loader.js.map +1 -1
  38. package/dist/lib/config/validator.js +157 -88
  39. package/dist/lib/config/validator.js.map +1 -1
  40. package/dist/lib/generators/file-generators/cache.js +2 -10
  41. package/dist/lib/generators/file-generators/cache.js.map +1 -1
  42. package/dist/lib/generators/file-generators/client-injector.js +50 -11
  43. package/dist/lib/generators/file-generators/client-injector.js.map +1 -1
  44. package/dist/lib/generators/file-generators/fetch-wrapper.js +92 -64
  45. package/dist/lib/generators/file-generators/fetch-wrapper.js.map +1 -1
  46. package/dist/lib/generators/file-generators/generate-hooks.js +1 -1
  47. package/dist/lib/generators/file-generators/generate-hooks.js.map +1 -1
  48. package/dist/lib/generators/file-generators/gql-wrapper.js +1 -14
  49. package/dist/lib/generators/file-generators/gql-wrapper.js.map +1 -1
  50. package/dist/lib/generators/file-generators/guide-generator.js +6 -640
  51. package/dist/lib/generators/file-generators/guide-generator.js.map +1 -1
  52. package/dist/lib/generators/file-generators/invalidation-tags.js +206 -19
  53. package/dist/lib/generators/file-generators/invalidation-tags.js.map +1 -1
  54. package/dist/lib/generators/file-generators/manifest.js +2 -2
  55. package/dist/lib/generators/file-generators/manifest.js.map +1 -1
  56. package/dist/lib/generators/file-generators/mutation-queue.js +29 -9
  57. package/dist/lib/generators/file-generators/mutation-queue.js.map +1 -1
  58. package/dist/lib/generators/file-generators/push.js +6 -7
  59. package/dist/lib/generators/file-generators/push.js.map +1 -1
  60. package/dist/lib/generators/file-generators/quick-readme.js +8 -125
  61. package/dist/lib/generators/file-generators/quick-readme.js.map +1 -1
  62. package/dist/lib/generators/file-generators/server-push.js +11 -1
  63. package/dist/lib/generators/file-generators/server-push.js.map +1 -1
  64. package/dist/lib/generators/file-generators/sw-generator-build.js +8 -9
  65. package/dist/lib/generators/file-generators/sw-generator-build.js.map +1 -1
  66. package/dist/lib/generators/file-generators/sw-injector.js +2 -1
  67. package/dist/lib/generators/file-generators/sw-injector.js.map +1 -1
  68. package/dist/lib/generators/file-generators/type-definitions.js +0 -3
  69. package/dist/lib/generators/file-generators/type-definitions.js.map +1 -1
  70. package/dist/lib/generators/sw-generator.js +8 -18
  71. package/dist/lib/generators/sw-generator.js.map +1 -1
  72. package/dist/lib/generators/sw-sections/activate-handler.js.map +1 -1
  73. package/dist/lib/generators/sw-sections/assemble-sw.js +14 -11
  74. package/dist/lib/generators/sw-sections/assemble-sw.js.map +1 -1
  75. package/dist/lib/generators/sw-sections/background-sync-handler.js +59 -17
  76. package/dist/lib/generators/sw-sections/background-sync-handler.js.map +1 -1
  77. package/dist/lib/generators/sw-sections/config-header.js +2 -2
  78. package/dist/lib/generators/sw-sections/config-header.js.map +1 -1
  79. package/dist/lib/generators/sw-sections/fetch-handler.js +437 -136
  80. package/dist/lib/generators/sw-sections/fetch-handler.js.map +1 -1
  81. package/dist/lib/generators/sw-sections/message-handler.js +55 -15
  82. package/dist/lib/generators/sw-sections/message-handler.js.map +1 -1
  83. package/dist/lib/generators/sw-sections/sw-push.js +67 -0
  84. package/dist/lib/generators/sw-sections/sw-push.js.map +1 -0
  85. package/dist/lib/generators/sw-sections/tag-management.js +60 -20
  86. package/dist/lib/generators/sw-sections/tag-management.js.map +1 -1
  87. package/dist/lib/generators/swoff-files-generator.js +5 -15
  88. package/dist/lib/generators/swoff-files-generator.js.map +1 -1
  89. package/dist/lib/shared/config-types.js +121 -71
  90. package/dist/lib/shared/config-types.js.map +1 -1
  91. package/dist/lib/shared/glob-matcher.js +77 -0
  92. package/dist/lib/shared/glob-matcher.js.map +1 -0
  93. package/dist/lib/utils/tty-status.js +21 -0
  94. package/dist/lib/utils/tty-status.js.map +1 -0
  95. package/docs/API.md +565 -0
  96. package/docs/ARCHITECTURE.md +374 -0
  97. package/docs/CLI.md +148 -0
  98. package/docs/COMPARISON.md +192 -0
  99. package/docs/CONFIG.md +277 -0
  100. package/docs/ECOSYSTEM.md +33 -0
  101. package/package.json +2 -1
  102. package/templates/hooks/useAuth.jsx +2 -2
  103. package/templates/hooks/useAuth.tsx +2 -2
  104. package/templates/hooks/useCachedFetch.jsx +3 -28
  105. package/templates/hooks/useCachedFetch.tsx +2 -33
  106. package/templates/hooks/useMutation.tsx +13 -1
  107. package/templates/hooks/useMutationQueue.jsx +2 -1
  108. package/templates/hooks/useMutationQueue.tsx +2 -1
  109. package/templates/hooks/useMutationState.jsx +8 -22
  110. package/templates/hooks/useMutationState.tsx +8 -24
  111. package/templates/hooks/usePushSubscription.jsx +3 -3
  112. package/templates/hooks/usePushSubscription.tsx +3 -3
@@ -6,24 +6,28 @@
6
6
  * 2. URL pattern match — from features.serviceWorker.strategies in swoff.config.json
7
7
  * 3. defaultStrategy — fallback from features.serviceWorker.defaultStrategy
8
8
  *
9
- * Each tier also carries: staleTime, maxCacheEntries, maxCacheAge
10
- * Per-request values override route values, which override globals.
9
+ * Strategies:
10
+ * - cache-first: Serve cache if exists, fetch if missing. No bg refresh.
11
+ * - network-first: Try network first, fall back to cache on failure.
12
+ * - stale-while-revalidate: Serve cache + always refresh in background.
13
+ * - cache-only: Never fetch. Cache hit or 404.
14
+ * - network-only: Never cache. Always fetch.
15
+ * - reactive: Serve cache + refresh on configurable triggers (interval,
16
+ * onFocus, onReconnect). staleTime gates ALL triggers.
11
17
  *
12
- * With staleTime enabled, every strategy gains a "fresh" window:
13
- * - cache-first: returns cached immediately, background refresh if stale
14
- * - network-first: skips network if fresh, tries network if stale
15
- * - stale-while-revalidate: skips refresh if fresh, refreshes if stale
16
- * - cache-only: background refresh if stale (best effort)
17
- * - network-only: unaffected (never caches)
18
+ * staleTime, refetchInterval, refetchOnFocus, refetchOnReconnect are
19
+ * reactive-only fields. Non-reactive strategies ignore them.
20
+ *
21
+ * Background refreshes use a shared batch queue with rate limiting.
22
+ * On successful refresh, CACHE_UPDATED is posted to all clients.
18
23
  */
19
- export function generateFetchHandler(swConfig, tagInvalidation) {
20
- const { defaultStrategy, strategies, cacheStrategy = "all", staleTime, maxCacheEntries, maxCacheAge, navigationPreload, navigationMode, spaEntry } = swConfig;
21
- const navMode = navigationMode ?? "spa";
22
- const spaPath = spaEntry ?? "/index.html";
24
+ export function generateFetchHandler(swConfig, tagInvalidation, mutationQueueEnabled) {
25
+ const { strategy: { default: defaultStrategy, patterns: strategies, mode: cacheStrategy = "all", normalizeKey: normalizeCacheKey, ignoreQueryParams }, navigation: { mode: navMode = "spa", preload: navigationPreload, fallback: spaPath = "/index.html" }, refetchQueue: { batchSize: refetchBatchSize = 5, batchDelayMs: refetchBatchDelayMs = 1000, maxRetries: refetchMaxRetries = 3, retryDelayMs: refetchRetryDelayMs = 1000 } } = swConfig;
26
+ const { staleTime: globalStaleTime, refetchInterval: globalRefetchInterval, refetchOnReconnect: globalRefetchOnReconnect, refetchOnFocus: globalRefetchOnFocus } = swConfig.strategy.reactive?.defaults || {};
23
27
  const staleVersionCode = tagInvalidation ? `
24
28
  cleanStaleVersions();
25
29
  if (staleVersions.has(cacheKey(request))) {
26
- event.waitUntil(refreshCache(request).then(() => staleVersions.delete(cacheKey(request))));
30
+ queueRefresh(cacheKey(request), new URL(request.url).href);
27
31
  }` : "";
28
32
  const tagCode = tagInvalidation ? `
29
33
  const tagsHeader = request.headers.get("X-SW-Cache-Tags");
@@ -33,16 +37,281 @@ export function generateFetchHandler(swConfig, tagInvalidation) {
33
37
  const tags = tagsHeader.split(",").map((t) => t.trim());
34
38
  await cacheTagUrl(cacheKeyUrl, actualUrl, tags);
35
39
  }` : "";
36
- const trimDecl = `const GLOBAL_MAX_ENTRIES = ${maxCacheEntries ?? 0};
37
- const GLOBAL_MAX_AGE = ${maxCacheAge ?? 0};
38
- const GLOBAL_STALE_TIME = ${staleTime ?? 0};`;
40
+ const reactGlobalDefaults = {
41
+ staleTime: globalStaleTime,
42
+ refetchInterval: globalRefetchInterval,
43
+ refetchOnReconnect: globalRefetchOnReconnect,
44
+ refetchOnFocus: globalRefetchOnFocus,
45
+ };
46
+ const reactivePatterns = Object.entries(strategies)
47
+ .filter(([, entry]) => {
48
+ const resolved = typeof entry === "string" ? { strategy: entry } : entry;
49
+ return resolved.strategy === "reactive";
50
+ })
51
+ .map(([pattern, entry]) => {
52
+ const resolved = typeof entry === "string" ? { strategy: entry } : entry;
53
+ return {
54
+ pattern,
55
+ staleTime: resolved.staleTime ?? reactGlobalDefaults.staleTime,
56
+ refetchInterval: resolved.refetchInterval ?? reactGlobalDefaults.refetchInterval,
57
+ refetchOnReconnect: resolved.refetchOnReconnect ?? reactGlobalDefaults.refetchOnReconnect,
58
+ refetchOnFocus: resolved.refetchOnFocus ?? reactGlobalDefaults.refetchOnFocus,
59
+ };
60
+ });
61
+ const trimDecl = `const REFETCH_BATCH_SIZE = ${refetchBatchSize};
62
+ const REFETCH_BATCH_DELAY_MS = ${refetchBatchDelayMs};
63
+ const REFRESH_MAX_RETRIES = ${refetchMaxRetries};
64
+ const REFRESH_RETRY_DELAY_MS = ${refetchRetryDelayMs};`;
65
+ const GLOB_CODE = "\n" +
66
+ "// --- Glob Pattern Matching ---\n" +
67
+ "// NOTE: This is an inline copy of the algorithm in shared/glob-matcher.ts.\n" +
68
+ "// Keep the two implementations in sync when making changes.\n" +
69
+ "\n" +
70
+ "function escapeGlobMeta(s) {\n" +
71
+ " return s.replace(/[.+^${}()|[\\]\\\\]/g, \"\\\\$$&\");\n" +
72
+ "}\n" +
73
+ "\n" +
74
+ "function globPartRe(part) {\n" +
75
+ " for (var o = \"\", i = 0; i < part.length; i++) {\n" +
76
+ " var c = part[i];\n" +
77
+ " if (c === \"*\") { o += \"[^/]*\"; }\n" +
78
+ " else if (c === \"?\") { o += \"[^/]\"; }\n" +
79
+ " else if (c === \"{\") {\n" +
80
+ " var cl = part.indexOf(\"}\", i);\n" +
81
+ " if (cl === -1) { o += \"\\\\\" + c; }\n" +
82
+ " else {\n" +
83
+ " o += \"(?:\" + part.slice(i + 1, cl).split(\",\").map(function(s) { return escapeGlobMeta(s.trim()); }).join(\"|\") + \")\";\n" +
84
+ " i = cl;\n" +
85
+ " }\n" +
86
+ " } else { o += \"\\\\\" + c; }\n" +
87
+ " }\n" +
88
+ " return o;\n" +
89
+ "}\n" +
90
+ "\n" +
91
+ "function matchGlob(path, pattern) {\n" +
92
+ " if (pattern.charAt(0) === \"!\") return !matchGlob(path, pattern.slice(1));\n" +
93
+ " var parts = pattern.split(\"/\").filter(Boolean);\n" +
94
+ " var pps = path.split(\"/\").filter(Boolean);\n" +
95
+ " var pi = 0, ppi = 0;\n" +
96
+ " while (pi < parts.length && ppi < pps.length) {\n" +
97
+ " var part = parts[pi];\n" +
98
+ " if (part === \"**\") {\n" +
99
+ " if (pi === parts.length - 1) return true;\n" +
100
+ " var nxt = parts[pi + 1];\n" +
101
+ " var found = -1;\n" +
102
+ " for (var j = ppi; j < pps.length; j++) {\n" +
103
+ " if (nxt.indexOf(\"*\") > -1 || nxt.indexOf(\"?\") > -1 || nxt.indexOf(\"{\") > -1) {\n" +
104
+ " if (new RegExp(\"^\" + globPartRe(nxt) + \"$\").test(pps[j])) { found = j; break; }\n" +
105
+ " } else if (nxt === pps[j]) { found = j; break; }\n" +
106
+ " }\n" +
107
+ " if (found === -1) return false;\n" +
108
+ " ppi = found;\n" +
109
+ " pi++;\n" +
110
+ " continue;\n" +
111
+ " }\n" +
112
+ " if (part.indexOf(\"*\") > -1 || part.indexOf(\"?\") > -1 || part.indexOf(\"{\") > -1) {\n" +
113
+ " if (!new RegExp(\"^\" + globPartRe(part) + \"$\").test(pps[ppi])) return false;\n" +
114
+ " } else if (part !== pps[ppi]) return false;\n" +
115
+ " pi++;\n" +
116
+ " ppi++;\n" +
117
+ " }\n" +
118
+ " return pi === parts.length && ppi === pps.length;\n" +
119
+ "}\n" +
120
+ "\n";
39
121
  return `${trimDecl}
122
+ // --- Batch Refresh Queue ---
123
+
124
+ const _refreshQueue = new Map();
125
+ let _refreshQueueProcessing = false;
126
+ let _refreshQueuePromise = null;
127
+
128
+ function queueRefresh(cacheKeyUrl, actualUrl${tagInvalidation ? ", tags" : ""}) {
129
+ // Use Map keyed by cacheKey for proper deduplication${tagInvalidation ? `
130
+ // Don't let a tagless (SWR/reactive) refresh override an invalidation-triggered entry with tags
131
+ if (_refreshQueue.has(cacheKeyUrl) && !tags) return;` : ""}
132
+ _refreshQueue.set(cacheKeyUrl, { cacheKey: cacheKeyUrl, actualUrl: actualUrl || cacheKeyUrl, retryCount: 0${tagInvalidation ? ", tags: tags || null" : ""} });
133
+ if (!_refreshQueuePromise) {
134
+ _refreshQueuePromise = _processRefreshQueue().finally(() => {
135
+ _refreshQueuePromise = null;
136
+ if (_refreshQueue.size > 0) queueRefresh();
137
+ });
138
+ }
139
+ return _refreshQueuePromise;
140
+ }
141
+
142
+ function resolveActualUrl(entry) {
143
+ const url = entry.actualUrl || entry.cacheKey;
144
+ if (url.includes("/__swc/")) {
145
+ // Virtual cache key — use the stored actual URL from tag registry
146
+ return url;
147
+ }
148
+ return url;
149
+ }
150
+
151
+ async function _processRefreshQueue() {
152
+ while (_refreshQueue.size > 0) {
153
+ const batch = [];
154
+ for (const [cacheKey, entry] of _refreshQueue) {
155
+ if (batch.length >= REFETCH_BATCH_SIZE) break;
156
+ batch.push(entry);
157
+ _refreshQueue.delete(cacheKey);
158
+ }
159
+ if (batch.length === 0) break;
160
+
161
+ const clients = await self.clients.matchAll();
162
+
163
+ await Promise.allSettled(batch.map(async (entry) => {
164
+ const fetchUrl = resolveActualUrl(entry);
165
+ try {
166
+ const response = await fetch(fetchUrl);
167
+ if (response.ok) {
168
+ const request = new Request(entry.cacheKey);
169
+ await storeRuntime(request, response);${tagInvalidation ? `
170
+ if (entry.tags && typeof cacheTagUrl !== "undefined") {
171
+ await cacheTagUrl(entry.cacheKey, fetchUrl, entry.tags);
172
+ }` : ""}
173
+ // Clean up stale version tracking on successful refresh
174
+ if (typeof staleVersions !== "undefined" && staleVersions.has(entry.cacheKey)) {
175
+ staleVersions.delete(entry.cacheKey);
176
+ }
177
+ for (const client of clients) {
178
+ client.postMessage({ type: "CACHE_UPDATED", url: fetchUrl });
179
+ }
180
+ }
181
+ } catch {
182
+ // Refresh failed — retry with exponential backoff
183
+ if (entry.retryCount < REFRESH_MAX_RETRIES) {
184
+ entry.retryCount++;
185
+ const delay = REFRESH_RETRY_DELAY_MS * Math.pow(2, entry.retryCount - 1);
186
+ _refreshQueue.set(entry.cacheKey, entry);
187
+ // Schedule re-processing after delay
188
+ setTimeout(() => {
189
+ if (!_refreshQueuePromise) {
190
+ _refreshQueuePromise = _processRefreshQueue().finally(() => {
191
+ _refreshQueuePromise = null;
192
+ if (_refreshQueue.size > 0) queueRefresh();
193
+ });
194
+ }
195
+ }, delay);
196
+ }
197
+ }
198
+ }));
199
+
200
+ if (_refreshQueue.size > 0 && REFETCH_BATCH_DELAY_MS > 0) {
201
+ await new Promise(r => setTimeout(r, REFETCH_BATCH_DELAY_MS));
202
+ }
203
+ }
204
+ _refreshQueueProcessing = false;
205
+ }
206
+
207
+ // --- Reactive Patterns (extracted for interval/focus/reconnect) ---
208
+
209
+ const REACTIVE_PATTERNS = ${JSON.stringify(reactivePatterns)};
210
+
211
+ function findReactiveConfig(url) {
212
+ const path = new URL(url).pathname;
213
+ for (const cfg of REACTIVE_PATTERNS) {
214
+ if (matchGlob(path, cfg.pattern)) return cfg;
215
+ }
216
+ return null;
217
+ }
218
+
219
+ function shouldReactiveRefresh(cached, config) {
220
+ if (!config.staleTime && config.staleTime !== 0) return true;
221
+ if (config.staleTime === 0) return true;
222
+ return isStale(cached, config.staleTime);
223
+ }
224
+
225
+ // --- Reactive Interval Timers ---
226
+
227
+ ${(() => {
228
+ const patternsWithInterval = reactivePatterns.filter((p) => !!p.refetchInterval && p.refetchInterval > 0);
229
+ if (patternsWithInterval.length === 0)
230
+ return "";
231
+ return `// Start intervals for reactive patterns with refetchInterval
232
+ (async () => {
233
+ ${patternsWithInterval.map(p => ` setInterval(async () => {
234
+ const cache = await caches.open(CACHE_NAME_RUNTIME);
235
+ const keys = await cache.keys();
236
+ for (const request of keys) {
237
+ const url = new URL(request.url);
238
+ if (url.pathname.startsWith("/__swc/")) continue;
239
+ if (!matchGlob(url.pathname, "${p.pattern}")) continue;
240
+ const config = findReactiveConfig(url.href);
241
+ if (!config) continue;
242
+ const cached = await cache.match(request);
243
+ if (cached && shouldReactiveRefresh(cached, config)) {
244
+ queueRefresh(request.url, url.href);
245
+ }
246
+ }
247
+ }, ${p.refetchInterval * 1000});`).join("\n")}
248
+ })();
249
+ `;
250
+ })()}
251
+ async function handleOnline() {
252
+ const clients = await self.clients.matchAll();
253
+ if (clients.length === 0) return;
254
+
255
+ // Step 1: Retry stale version entries (failed refetches after invalidation)
256
+ if (typeof staleVersions !== "undefined") {
257
+ const staleUrls = [...staleVersions.keys()];
258
+ for (const url of staleUrls) {
259
+ queueRefresh(url, url);
260
+ }
261
+ }
262
+
263
+ // Step 2: Refresh reactive entries with refetchOnReconnect
264
+ const cache = await caches.open(CACHE_NAME_RUNTIME);
265
+ const keys = await cache.keys();
266
+ for (const request of keys) {
267
+ const url = new URL(request.url);
268
+ if (url.pathname.startsWith("/__swc/")) continue;
269
+ const config = findReactiveConfig(url.href);
270
+ if (!config || !config.refetchOnReconnect) continue;
271
+ const cached = await cache.match(request);
272
+ if (cached && shouldReactiveRefresh(cached, config)) {
273
+ queueRefresh(request.url, url.href);
274
+ }
275
+ }
276
+ }
277
+
278
+ async function handleOnFocus() {
279
+ const cache = await caches.open(CACHE_NAME_RUNTIME);
280
+ const keys = await cache.keys();
281
+ for (const request of keys) {
282
+ const url = new URL(request.url);
283
+ if (url.pathname.startsWith("/__swc/")) continue;
284
+ const config = findReactiveConfig(url.href);
285
+ if (!config || !config.refetchOnFocus) continue;
286
+ const cached = await cache.match(request);
287
+ if (cached && shouldReactiveRefresh(cached, config)) {
288
+ queueRefresh(request.url, url.href);
289
+ }
290
+ }
291
+ }
292
+
40
293
  // --- Cache Key ---
41
294
 
42
295
  function cacheKey(request) {
43
296
  const key = request.headers.get("X-SW-Cache-Key");
44
297
  if (key) return new URL("/__swc/" + key, self.location.origin).href;
45
- return new URL(request.url).href;
298
+ const url = new URL(request.url);${normalizeCacheKey ? `
299
+ // Sort query params alphabetically for consistent cache keys
300
+ if (url.search) {
301
+ const params = new URLSearchParams(url.search);
302
+ params.sort();
303
+ url.search = params.toString();
304
+ }` : ""}${ignoreQueryParams && ignoreQueryParams.length > 0
305
+ ? `
306
+ // Strip configured query params from cache key
307
+ const ignore = ${JSON.stringify(ignoreQueryParams)};
308
+ if (url.search) {
309
+ const params = new URLSearchParams(url.search);
310
+ for (const key of ignore) params.delete(key);
311
+ url.search = params.toString();
312
+ }`
313
+ : ""}
314
+ return url.href;
46
315
  }
47
316
 
48
317
  // --- Cache Helpers ---
@@ -99,7 +368,7 @@ function isStale(response, staleTimeSeconds) {
99
368
  return Date.now() - Number(cachedAt) > staleTimeSeconds * 1000;
100
369
  }
101
370
 
102
- // --- Strategy Selection (3-tier config resolution) ---
371
+ ${GLOB_CODE}// --- Strategy Selection (3-tier config resolution) ---
103
372
 
104
373
  function resolveStrategyEntry(entry) {
105
374
  return typeof entry === "string" ? { strategy: entry } : entry;
@@ -108,55 +377,137 @@ function resolveStrategyEntry(entry) {
108
377
  function determineCacheStrategy(request, customStrategies, globalDefaults) {
109
378
  const override = request.headers.get("X-SW-Strategy");
110
379
  if (override) {
111
- return {
112
- strategy: override,
113
- staleTime: Number(request.headers.get("X-SW-Stale-Time")) || globalDefaults.staleTime,
114
- maxCacheEntries: Number(request.headers.get("X-SW-Max-Entries")) || globalDefaults.maxCacheEntries,
115
- maxCacheAge: Number(request.headers.get("X-SW-Max-Age")) || globalDefaults.maxCacheAge,
116
- };
380
+ return { strategy: override };
117
381
  }
118
382
  const path = new URL(request.url).pathname;
119
383
  for (const [pattern, entry] of Object.entries(customStrategies)) {
120
- if (path.startsWith(pattern.replace("*", ""))) {
384
+ if (matchGlob(path, pattern)) {
121
385
  const resolved = resolveStrategyEntry(entry);
122
- return {
123
- strategy: resolved.strategy,
124
- staleTime: resolved.staleTime ?? globalDefaults.staleTime,
125
- maxCacheEntries: resolved.maxCacheEntries ?? globalDefaults.maxCacheEntries,
126
- maxCacheAge: resolved.maxCacheAge ?? globalDefaults.maxCacheAge,
127
- };
386
+ return { strategy: resolved.strategy };
128
387
  }
129
388
  }
130
- return {
131
- strategy: globalDefaults.defaultStrategy,
132
- staleTime: globalDefaults.staleTime,
133
- maxCacheEntries: globalDefaults.maxCacheEntries,
134
- maxCacheAge: globalDefaults.maxCacheAge,
135
- };
389
+ return { strategy: globalDefaults.defaultStrategy };
390
+ }
391
+
392
+ function determineCacheStrategyForUrl(url, customStrategies, globalDefaults) {
393
+ const path = new URL(url).pathname;
394
+ for (const [pattern, entry] of Object.entries(customStrategies)) {
395
+ if (matchGlob(path, pattern)) {
396
+ const resolved = resolveStrategyEntry(entry);
397
+ return { strategy: resolved.strategy };
398
+ }
399
+ }
400
+ return { strategy: globalDefaults.defaultStrategy };
136
401
  }
137
402
 
138
403
  function applyStrategy(event, request, config) {
139
- const { strategy, staleTime, maxCacheEntries, maxCacheAge } = config;
140
- if (strategy === "stale-while-revalidate") {
141
- event.respondWith(staleWhileRevalidate(event, request, staleTime, maxCacheEntries, maxCacheAge));
404
+ const { strategy } = config;
405
+ if (strategy === "reactive") {
406
+ const reactiveCfg = findReactiveConfig(new URL(request.url).href);
407
+ event.respondWith(reactiveStrategy(event, request, reactiveCfg?.staleTime));
408
+ } else if (strategy === "stale-while-revalidate") {
409
+ event.respondWith(staleWhileRevalidate(event, request));
142
410
  } else if (strategy === "network-first") {
143
- event.respondWith(networkFirst(event, request, staleTime, maxCacheEntries, maxCacheAge));
411
+ event.respondWith(networkFirst(event, request));
144
412
  } else if (strategy === "cache-only") {
145
- event.respondWith(cacheOnly(event, request, staleTime, maxCacheEntries, maxCacheAge));
413
+ event.respondWith(cacheOnly(event, request));
146
414
  } else if (strategy === "network-only") {
147
415
  event.respondWith(networkOnly(event, request));
148
416
  } else {
149
- event.respondWith(cacheFirst(event, request, staleTime, maxCacheEntries, maxCacheAge));
417
+ event.respondWith(cacheFirst(event, request));
418
+ }
419
+ }
420
+
421
+ ${mutationQueueEnabled ? `// --- Mutation Queue IDB Helpers ---
422
+
423
+ const MUTATION_DB_NAME = "swoff-queue";
424
+ const MUTATION_STORE_NAME = "mutations";
425
+
426
+ function openMutationQueueDB() {
427
+ return new Promise((resolve, reject) => {
428
+ const request = indexedDB.open(MUTATION_DB_NAME, 1);
429
+ request.onupgradeneeded = (e) => {
430
+ const db = e.target.result;
431
+ if (!db.objectStoreNames.contains(MUTATION_STORE_NAME)) {
432
+ const store = db.createObjectStore(MUTATION_STORE_NAME, { keyPath: "id" });
433
+ store.createIndex("by-timestamp", "timestamp");
434
+ }
435
+ };
436
+ request.onsuccess = (e) => resolve(e.target.result);
437
+ request.onerror = (e) => reject(e.target.error);
438
+ });
439
+ }
440
+
441
+ async function storeMutationInSW(request) {
442
+ let body, bodyType;
443
+ try {
444
+ body = await request.clone().json();
445
+ bodyType = "json";
446
+ } catch {
447
+ body = await request.clone().text();
448
+ bodyType = "text";
150
449
  }
450
+
451
+ const tagsHeader = request.headers.get("X-SW-Invalidate-Tags");
452
+ const tags = tagsHeader ? tagsHeader.split(",").map(function(t) { return t.trim(); }) : [];
453
+
454
+ const db = await openMutationQueueDB();
455
+ const tx = db.transaction(MUTATION_STORE_NAME, "readwrite");
456
+ tx.objectStore(MUTATION_STORE_NAME).add({
457
+ id: crypto.randomUUID(),
458
+ method: request.method,
459
+ url: new URL(request.url).href,
460
+ body,
461
+ bodyType,
462
+ headers: {},
463
+ timestamp: Date.now(),
464
+ retryCount: 0,
465
+ nextRetryAt: 0,
466
+ tags,
467
+ });
468
+ await new Promise(function(resolve, reject) {
469
+ tx.oncomplete = function() { resolve(); };
470
+ tx.onerror = function() { reject(tx.error); };
471
+ });
151
472
  }
152
473
 
153
- self.addEventListener("fetch", (event) => {
154
- const { request } = event;
474
+ async function handleMutation(event) {
475
+ const request = event.request;
476
+ if (request.headers.get("X-SW-No-Queue") === "true") {
477
+ return fetch(request.clone());
478
+ }
479
+ try {
480
+ return await fetch(request.clone());
481
+ } catch {
482
+ await storeMutationInSW(request);
483
+ // Notify open clients that a mutation was stored so they can try to process
484
+ const clients = await self.clients.matchAll();
485
+ clients.forEach(function(client) {
486
+ client.postMessage({ type: "MUTATION_STORED" });
487
+ });
488
+ const queuedBody = JSON.stringify({ queued: true });
489
+ return new Response(queuedBody, {
490
+ status: 202,
491
+ statusText: "Accepted",
492
+ headers: { "Content-Type": "application/json", "X-SW-Mutation-Queued": "true" },
493
+ });
494
+ }
495
+ }
496
+
497
+ ` : ""}self.addEventListener("fetch", (event) => {
498
+ const { request } = event;${mutationQueueEnabled ? `
155
499
  if (request.method !== "GET" && request.method !== "HEAD") {
500
+ if (request.headers.get("X-SW-Cache-Strategy") === "mutation") {
501
+ event.respondWith(handleMutation(event));
502
+ return;
503
+ }
156
504
  if (!request.headers.get("X-SW-Cache-Key")) return;
157
- }
505
+ }` : `
506
+ if (request.method !== "GET" && request.method !== "HEAD") {
507
+ if (!request.headers.get("X-SW-Cache-Key")) return;
508
+ }`}
158
509
  ${cacheStrategy === "explicit-only" ? `if (!request.headers.get("X-SW-Cache-Strategy")) return;` : ""}
159
- applyStrategy(event, request, determineCacheStrategy(event.request, ${JSON.stringify(strategies)}, { defaultStrategy: "${defaultStrategy}", staleTime: GLOBAL_STALE_TIME, maxCacheEntries: GLOBAL_MAX_ENTRIES, maxCacheAge: GLOBAL_MAX_AGE }));
510
+ applyStrategy(event, request, determineCacheStrategy(event.request, ${JSON.stringify(strategies)}, { defaultStrategy: "${defaultStrategy}" }));
160
511
  });
161
512
 
162
513
  // --- Strategies ---
@@ -171,17 +522,9 @@ async function fetchWithPreload(event, request) {
171
522
  }
172
523
  ` : ""}const _fetch = ${navigationPreload ? "fetchWithPreload" : `(event, request) => fetch(request)`};
173
524
 
174
- ${generateTrimCode(maxCacheEntries, maxCacheAge)}
175
- function _trim(cacheName, maxEntries, maxAge) {
176
- ${maxCacheEntries || maxCacheAge ? ` trimRuntimeCache(cacheName, maxEntries, maxAge);` : ""}
177
- }
178
-
179
- async function cacheFirst(event, request, staleTime, maxEntries, maxAge) {
525
+ async function cacheFirst(event, request) {
180
526
  const cached = await fromRuntime(request);
181
527
  if (cached) {${staleVersionCode}
182
- if (isStale(cached, staleTime)) {
183
- event.waitUntil(refreshCache(request));
184
- }
185
528
  return markFromCache(cached);
186
529
  }
187
530
 
@@ -193,94 +536,86 @@ async function cacheFirst(event, request, staleTime, maxEntries, maxAge) {
193
536
 
194
537
  const response = await _fetch(event, request);
195
538
  if (response.ok) {
196
- event.waitUntil(
197
- (async () => {
198
- await cacheResponse(request, response);
199
- _trim(CACHE_NAME_RUNTIME, maxEntries, maxAge);
200
- })(),
201
- );
539
+ const responseToCache = response.clone();
540
+ event.waitUntil(cacheResponse(request, responseToCache));
202
541
  }
203
542
  return response;
204
543
  }
205
544
 
206
- async function networkFirst(event, request, staleTime, maxEntries, maxAge) {
207
- // If cached and fresh (within staleTime), skip network entirely
208
- if (staleTime > 0) {
209
- const cached = await fromRuntime(request);
210
- if (cached && !isStale(cached, staleTime)) {
211
- return markFromCache(cached);
212
- }
213
- }
214
-
545
+ async function networkFirst(event, request) {
215
546
  try {
216
547
  const response = await _fetch(event, request);
217
548
  if (response.ok) {
218
- event.waitUntil(
219
- (async () => {
220
- await cacheResponse(request, response);
221
- _trim(CACHE_NAME_RUNTIME, maxEntries, maxAge);
222
- })(),
223
- );
549
+ const responseToCache = response.clone();
550
+ event.waitUntil(cacheResponse(request, responseToCache));
224
551
  }
225
552
  return response;
226
553
  } catch {
227
554
  const cached = await fromRuntime(request);
228
- if (cached) return cached;
555
+ if (cached) {
556
+ return markFromCache(cached);
557
+ }
229
558
 
230
559
  const precached = await fromPrecache(request);
231
- if (precached) return precached;
560
+ if (precached) return markFromCache(precached);
232
561
 
233
562
  const fallback = await fromSpaFallback(request);
234
563
  if (fallback) return fallback;
235
564
 
236
- throw new Error("Request failed and no cached response available");
565
+ throw new Error("Network request failed and no cached response available");
237
566
  }
238
567
  }
239
568
 
240
- async function staleWhileRevalidate(event, request, staleTime, maxEntries, maxAge) {
569
+ async function staleWhileRevalidate(event, request) {
241
570
  const cached = await fromRuntime(request);
242
571
  if (cached) {
243
- // Skip background refresh if still fresh
244
- if (!isStale(cached, staleTime)) {
245
- return markFromCache(cached);
246
- }
247
- event.waitUntil(refreshCache(request));
572
+ event.waitUntil(queueRefresh(cacheKey(request), new URL(request.url).href));
248
573
  return markFromCache(cached);
249
574
  }
250
575
 
251
576
  const precached = await fromPrecache(request);
252
577
  if (precached) {
253
- event.waitUntil(refreshCache(request));
578
+ event.waitUntil(queueRefresh(cacheKey(request), new URL(request.url).href));
254
579
  return markFromCache(precached);
255
580
  }
256
581
 
257
582
  const response = await _fetch(event, request);
258
583
  if (response.ok) {
259
- await cacheResponse(request, response);
260
- _trim(CACHE_NAME_RUNTIME, maxEntries, maxAge);
584
+ const responseToCache = response.clone();
585
+ await cacheResponse(request, responseToCache);
261
586
  }
262
587
  return response;
263
588
  }
264
589
 
265
- async function refreshCache(request) {
266
- try {
267
- const key = request.headers.get("X-SW-Cache-Key");
268
- const fetchRequest = key ? new Request(new URL(request.url).href, { method: request.method, headers: request.headers, body: request.method !== "GET" ? request.body : undefined }) : request;
269
- const response = await fetch(fetchRequest);
270
- if (response.ok) {
271
- await storeRuntime(request, response);
590
+ async function reactiveStrategy(event, request, staleTime) {
591
+ const cached = await fromRuntime(request);
592
+ // Per-request staleTime header overrides config-level staleTime
593
+ const headerStale = request.headers.get("X-SW-Stale-Time");
594
+ const effectiveStaleTime = headerStale !== null ? Number(headerStale) : staleTime;
595
+ if (cached) {${staleVersionCode}
596
+ if (shouldReactiveRefresh(cached, { staleTime: effectiveStaleTime })) {
597
+ event.waitUntil(queueRefresh(cacheKey(request), new URL(request.url).href));
272
598
  }
273
- } catch {
274
- // Background refresh failed - stale cache remains usable
599
+ return markFromCache(cached);
275
600
  }
601
+
602
+ const precached = await fromPrecache(request);
603
+ if (precached) return markFromCache(precached);
604
+
605
+ const fallback = await fromSpaFallback(request);
606
+ if (fallback) return fallback;
607
+
608
+ const response = await _fetch(event, request);
609
+ if (response.ok) {
610
+ const responseToCache = response.clone();
611
+ await cacheResponse(request, responseToCache);
612
+ }
613
+ return response;
276
614
  }
277
615
 
278
- async function cacheOnly(event, request, staleTime, maxEntries, maxAge) {
616
+ async function cacheOnly(event, request) {
279
617
  const cached = await fromRuntime(request);
280
618
  if (cached) {${staleVersionCode}
281
- if (isStale(cached, staleTime)) {
282
- event.waitUntil(refreshCache(request));
283
- }
284
619
  return markFromCache(cached);
285
620
  }
286
621
 
@@ -294,38 +629,4 @@ async function networkOnly(event, request) {
294
629
  return _fetch(event, request);
295
630
  }`;
296
631
  }
297
- function generateTrimCode(maxCacheEntries, maxCacheAge) {
298
- if (!maxCacheEntries && !maxCacheAge)
299
- return "";
300
- return `
301
- async function trimRuntimeCache(cacheName, maxEntries, maxAge) {
302
- const _maxEntries = maxEntries ?? GLOBAL_MAX_ENTRIES;
303
- const _maxAge = maxAge ?? GLOBAL_MAX_AGE;
304
- const cache = await caches.open(cacheName);
305
-
306
- if (_maxEntries > 0) {
307
- const keys = await cache.keys();
308
- if (keys.length >= _maxEntries) {
309
- const toDelete = keys.slice(0, keys.length - _maxEntries + 1);
310
- await Promise.all(toDelete.map((key) => cache.delete(key)));
311
- }
312
- }
313
-
314
- if (_maxAge > 0) {
315
- const keys = await cache.keys();
316
- const now = Date.now();
317
- for (const request of keys) {
318
- const response = await cache.match(request);
319
- const dateHeader = response?.headers.get("date");
320
- if (dateHeader) {
321
- const age = now - new Date(dateHeader).getTime();
322
- if (age > _maxAge) {
323
- await cache.delete(request);
324
- }
325
- }
326
- }
327
- }
328
- }
329
- `;
330
- }
331
632
  //# sourceMappingURL=fetch-handler.js.map