@swoff/cli 0.3.7 → 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 (121) hide show
  1. package/README.md +53 -606
  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 +38 -18
  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 +170 -74
  17. package/dist/__tests__/validator.test.js.map +1 -1
  18. package/dist/index.js +12 -11
  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 +52 -33
  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 +5 -5
  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 +37 -12
  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 +192 -42
  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 +51 -12
  43. package/dist/lib/generators/file-generators/client-injector.js.map +1 -1
  44. package/dist/lib/generators/file-generators/fetch-wrapper.js +127 -58
  45. package/dist/lib/generators/file-generators/fetch-wrapper.js.map +1 -1
  46. package/dist/lib/generators/file-generators/generate-hooks.js +6 -2
  47. package/dist/lib/generators/file-generators/generate-hooks.js.map +1 -1
  48. package/dist/lib/generators/file-generators/gql-wrapper.js +139 -0
  49. package/dist/lib/generators/file-generators/gql-wrapper.js.map +1 -0
  50. package/dist/lib/generators/file-generators/guide-generator.js +6 -436
  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 +140 -52
  57. package/dist/lib/generators/file-generators/mutation-queue.js.map +1 -1
  58. package/dist/lib/generators/file-generators/mutation-state.js +126 -0
  59. package/dist/lib/generators/file-generators/mutation-state.js.map +1 -0
  60. package/dist/lib/generators/file-generators/push.js +6 -7
  61. package/dist/lib/generators/file-generators/push.js.map +1 -1
  62. package/dist/lib/generators/file-generators/quick-readme.js +8 -75
  63. package/dist/lib/generators/file-generators/quick-readme.js.map +1 -1
  64. package/dist/lib/generators/file-generators/server-push.js +146 -0
  65. package/dist/lib/generators/file-generators/server-push.js.map +1 -0
  66. package/dist/lib/generators/file-generators/sw-generator-build.js +8 -9
  67. package/dist/lib/generators/file-generators/sw-generator-build.js.map +1 -1
  68. package/dist/lib/generators/file-generators/sw-injector.js +2 -1
  69. package/dist/lib/generators/file-generators/sw-injector.js.map +1 -1
  70. package/dist/lib/generators/file-generators/type-definitions.js +26 -1
  71. package/dist/lib/generators/file-generators/type-definitions.js.map +1 -1
  72. package/dist/lib/generators/sw-generator.js +8 -18
  73. package/dist/lib/generators/sw-generator.js.map +1 -1
  74. package/dist/lib/generators/sw-sections/activate-handler.js.map +1 -1
  75. package/dist/lib/generators/sw-sections/assemble-sw.js +19 -11
  76. package/dist/lib/generators/sw-sections/assemble-sw.js.map +1 -1
  77. package/dist/lib/generators/sw-sections/background-sync-handler.js +80 -24
  78. package/dist/lib/generators/sw-sections/background-sync-handler.js.map +1 -1
  79. package/dist/lib/generators/sw-sections/config-header.js +2 -2
  80. package/dist/lib/generators/sw-sections/config-header.js.map +1 -1
  81. package/dist/lib/generators/sw-sections/default-template.js +2 -1
  82. package/dist/lib/generators/sw-sections/default-template.js.map +1 -1
  83. package/dist/lib/generators/sw-sections/fetch-handler.js +461 -121
  84. package/dist/lib/generators/sw-sections/fetch-handler.js.map +1 -1
  85. package/dist/lib/generators/sw-sections/message-handler.js +55 -15
  86. package/dist/lib/generators/sw-sections/message-handler.js.map +1 -1
  87. package/dist/lib/generators/sw-sections/server-push-handler.js +114 -0
  88. package/dist/lib/generators/sw-sections/server-push-handler.js.map +1 -0
  89. package/dist/lib/generators/sw-sections/sw-push.js +67 -0
  90. package/dist/lib/generators/sw-sections/sw-push.js.map +1 -0
  91. package/dist/lib/generators/sw-sections/tag-management.js +62 -21
  92. package/dist/lib/generators/sw-sections/tag-management.js.map +1 -1
  93. package/dist/lib/generators/swoff-files-generator.js +12 -16
  94. package/dist/lib/generators/swoff-files-generator.js.map +1 -1
  95. package/dist/lib/shared/config-types.js +133 -45
  96. package/dist/lib/shared/config-types.js.map +1 -1
  97. package/dist/lib/shared/glob-matcher.js +77 -0
  98. package/dist/lib/shared/glob-matcher.js.map +1 -0
  99. package/dist/lib/utils/tty-status.js +21 -0
  100. package/dist/lib/utils/tty-status.js.map +1 -0
  101. package/docs/API.md +565 -0
  102. package/docs/ARCHITECTURE.md +374 -0
  103. package/docs/CLI.md +148 -0
  104. package/docs/COMPARISON.md +192 -0
  105. package/docs/CONFIG.md +277 -0
  106. package/docs/ECOSYSTEM.md +33 -0
  107. package/package.json +2 -1
  108. package/templates/hooks/useAuth.jsx +2 -2
  109. package/templates/hooks/useAuth.tsx +2 -2
  110. package/templates/hooks/useCachedFetch.jsx +21 -7
  111. package/templates/hooks/useCachedFetch.tsx +23 -11
  112. package/templates/hooks/useMutation.jsx +76 -0
  113. package/templates/hooks/useMutation.tsx +109 -0
  114. package/templates/hooks/useMutationQueue.jsx +20 -11
  115. package/templates/hooks/useMutationQueue.tsx +31 -15
  116. package/templates/hooks/useMutationState.jsx +13 -0
  117. package/templates/hooks/useMutationState.tsx +25 -0
  118. package/templates/hooks/usePrefetch.jsx +10 -0
  119. package/templates/hooks/usePrefetch.tsx +25 -0
  120. package/templates/hooks/usePushSubscription.jsx +3 -3
  121. package/templates/hooks/usePushSubscription.tsx +3 -3
@@ -6,65 +6,314 @@
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
- * Cache strategy mode (features.serviceWorker.cacheStrategy):
10
- * "all" — all GET/HEAD requests go through the strategy system (default)
11
- * "explicit-only" only requests with X-SW-Cache-Strategy header go through strategy;
12
- * plain fetch() calls pass through the SW unmodified
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.
13
17
  *
14
- * Request dispatch flow:
15
- * navigation (SPA fallback) precache strategy (if applicable) → pass-through
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.
16
23
  */
17
- export function generateFetchHandler(swConfig, tagInvalidation) {
18
- const { defaultStrategy, strategies, cacheStrategy = "all", maxCacheEntries, maxCacheAge, navigationPreload, navigationMode, spaEntry } = swConfig;
19
- const hasPerRouteLimits = Object.values(strategies || {}).some((s) => typeof s === "object" && (s.maxCacheEntries || s.maxCacheAge));
20
- const hasTrim = (maxCacheEntries ?? 0) > 0 || (maxCacheAge ?? 0) > 0 || hasPerRouteLimits;
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
- if (staleVersions.has(request.url)) {
26
- event.waitUntil(refreshCache(request).then(() => staleVersions.delete(request.url)));
29
+ if (staleVersions.has(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");
30
34
  if (tagsHeader) {
31
- const url = new URL(request.url).href;
35
+ const cacheKeyUrl = cacheKey(request);
36
+ const actualUrl = new URL(request.url).href;
32
37
  const tags = tagsHeader.split(",").map((t) => t.trim());
33
- await cacheTagUrl(url, tags);
38
+ await cacheTagUrl(cacheKeyUrl, actualUrl, tags);
34
39
  }` : "";
35
- const trimCode = hasTrim ? ` await trimRuntimeCache(CACHE_NAME_RUNTIME);\n` : "";
36
- const trimFunction = hasTrim ? `
37
- const MAX_CACHE_ENTRIES = ${maxCacheEntries ?? 0};
38
- const MAX_CACHE_AGE = ${maxCacheAge ?? 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";
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
+ }
39
141
 
40
- async function trimRuntimeCache(cacheName) {
41
- const cache = await caches.open(cacheName);
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
+ }
42
150
 
43
- if (MAX_CACHE_ENTRIES > 0) {
44
- const keys = await cache.keys();
45
- if (keys.length >= MAX_CACHE_ENTRIES) {
46
- const toDelete = keys.slice(0, keys.length - MAX_CACHE_ENTRIES + 1);
47
- await Promise.all(toDelete.map((key) => cache.delete(key)));
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);
48
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;
49
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 ---
50
226
 
51
- if (MAX_CACHE_AGE > 0) {
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);
52
235
  const keys = await cache.keys();
53
- const now = Date.now();
54
236
  for (const request of keys) {
55
- const response = await cache.match(request);
56
- const dateHeader = response?.headers.get("date");
57
- if (dateHeader) {
58
- const age = now - new Date(dateHeader).getTime();
59
- if (age > MAX_CACHE_AGE) {
60
- await cache.delete(request);
61
- }
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);
62
245
  }
63
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
+ }
64
275
  }
65
276
  }
66
- ` : "";
67
- return `${trimFunction}
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
+
293
+ // --- Cache Key ---
294
+
295
+ function cacheKey(request) {
296
+ const key = request.headers.get("X-SW-Cache-Key");
297
+ if (key) return new URL("/__swc/" + key, self.location.origin).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;
315
+ }
316
+
68
317
  // --- Cache Helpers ---
69
318
 
70
319
  async function fromPrecache(request) {
@@ -74,12 +323,19 @@ async function fromPrecache(request) {
74
323
 
75
324
  async function fromRuntime(request) {
76
325
  const cache = await caches.open(CACHE_NAME_RUNTIME);
77
- return cache.match(new URL(request.url).href);
326
+ return cache.match(cacheKey(request));
78
327
  }
79
328
 
80
329
  async function storeRuntime(request, response) {
81
330
  const cache = await caches.open(CACHE_NAME_RUNTIME);
82
- await cache.put(new URL(request.url).href, response.clone());
331
+ const headers = new Headers(response.headers);
332
+ headers.set("X-SW-Cached-At", String(Date.now()));
333
+ const cloned = new Response(response.body, {
334
+ status: response.status,
335
+ statusText: response.statusText,
336
+ headers,
337
+ });
338
+ await cache.put(cacheKey(request), cloned);
83
339
  }
84
340
 
85
341
  async function cacheResponse(request, response) {
@@ -105,42 +361,153 @@ function markFromCache(response) {
105
361
  });
106
362
  }
107
363
 
108
- // --- Strategy Selection ---
364
+ function isStale(response, staleTimeSeconds) {
365
+ if (!staleTimeSeconds || staleTimeSeconds <= 0) return false;
366
+ const cachedAt = response.headers.get("X-SW-Cached-At");
367
+ if (!cachedAt) return false;
368
+ return Date.now() - Number(cachedAt) > staleTimeSeconds * 1000;
369
+ }
370
+
371
+ ${GLOB_CODE}// --- Strategy Selection (3-tier config resolution) ---
109
372
 
110
373
  function resolveStrategyEntry(entry) {
111
374
  return typeof entry === "string" ? { strategy: entry } : entry;
112
375
  }
113
376
 
114
- function determineCacheStrategy(request, customStrategies, defaultStrategy) {
377
+ function determineCacheStrategy(request, customStrategies, globalDefaults) {
115
378
  const override = request.headers.get("X-SW-Strategy");
116
- if (override) return { strategy: override };
379
+ if (override) {
380
+ return { strategy: override };
381
+ }
117
382
  const path = new URL(request.url).pathname;
118
383
  for (const [pattern, entry] of Object.entries(customStrategies)) {
119
- if (path.startsWith(pattern.replace("*", ""))) return resolveStrategyEntry(entry);
384
+ if (matchGlob(path, pattern)) {
385
+ const resolved = resolveStrategyEntry(entry);
386
+ return { strategy: resolved.strategy };
387
+ }
388
+ }
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
+ }
120
399
  }
121
- return { strategy: defaultStrategy };
400
+ return { strategy: globalDefaults.defaultStrategy };
122
401
  }
123
402
 
124
403
  function applyStrategy(event, request, config) {
125
- const { strategy, maxCacheEntries, maxCacheAge } = config;
126
- if (strategy === "stale-while-revalidate") {
127
- event.respondWith(staleWhileRevalidate(event, request, 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));
128
410
  } else if (strategy === "network-first") {
129
- event.respondWith(networkFirst(event, request, maxCacheEntries, maxCacheAge));
411
+ event.respondWith(networkFirst(event, request));
130
412
  } else if (strategy === "cache-only") {
131
- event.respondWith(cacheOnly(event, request, maxCacheEntries, maxCacheAge));
413
+ event.respondWith(cacheOnly(event, request));
132
414
  } else if (strategy === "network-only") {
133
415
  event.respondWith(networkOnly(event, request));
134
416
  } else {
135
- event.respondWith(cacheFirst(event, request, 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";
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
+ });
472
+ }
473
+
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
+ });
136
494
  }
137
495
  }
138
496
 
139
- self.addEventListener("fetch", (event) => {
140
- const { request } = event;
141
- if (request.method !== "GET" && request.method !== "HEAD") return;
497
+ ` : ""}self.addEventListener("fetch", (event) => {
498
+ const { request } = event;${mutationQueueEnabled ? `
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
+ }
504
+ if (!request.headers.get("X-SW-Cache-Key")) return;
505
+ }` : `
506
+ if (request.method !== "GET" && request.method !== "HEAD") {
507
+ if (!request.headers.get("X-SW-Cache-Key")) return;
508
+ }`}
142
509
  ${cacheStrategy === "explicit-only" ? `if (!request.headers.get("X-SW-Cache-Strategy")) return;` : ""}
143
- applyStrategy(event, request, determineCacheStrategy(event.request, ${JSON.stringify(strategies)}, "${defaultStrategy}"));
510
+ applyStrategy(event, request, determineCacheStrategy(event.request, ${JSON.stringify(strategies)}, { defaultStrategy: "${defaultStrategy}" }));
144
511
  });
145
512
 
146
513
  // --- Strategies ---
@@ -155,43 +522,7 @@ async function fetchWithPreload(event, request) {
155
522
  }
156
523
  ` : ""}const _fetch = ${navigationPreload ? "fetchWithPreload" : `(event, request) => fetch(request)`};
157
524
 
158
- const GLOBAL_MAX_ENTRIES = ${maxCacheEntries ?? 0};
159
- const GLOBAL_MAX_AGE = ${maxCacheAge ?? 0};
160
-
161
- ${hasTrim ? `
162
- async function trimRuntimeCache(cacheName, maxEntries, maxAge) {
163
- const _maxEntries = maxEntries ?? GLOBAL_MAX_ENTRIES;
164
- const _maxAge = maxAge ?? GLOBAL_MAX_AGE;
165
- const cache = await caches.open(cacheName);
166
-
167
- if (_maxEntries > 0) {
168
- const keys = await cache.keys();
169
- if (keys.length >= _maxEntries) {
170
- const toDelete = keys.slice(0, keys.length - _maxEntries + 1);
171
- await Promise.all(toDelete.map((key) => cache.delete(key)));
172
- }
173
- }
174
-
175
- if (_maxAge > 0) {
176
- const keys = await cache.keys();
177
- const now = Date.now();
178
- for (const request of keys) {
179
- const response = await cache.match(request);
180
- const dateHeader = response?.headers.get("date");
181
- if (dateHeader) {
182
- const age = now - new Date(dateHeader).getTime();
183
- if (age > _maxAge) {
184
- await cache.delete(request);
185
- }
186
- }
187
- }
188
- }
189
- }
190
- ` : ""}function _trim(cacheName, maxEntries, maxAge) {
191
- ${hasTrim ? ` trimRuntimeCache(cacheName, maxEntries, maxAge);` : ""}
192
- }
193
-
194
- async function cacheFirst(event, request, maxEntries, maxAge) {
525
+ async function cacheFirst(event, request) {
195
526
  const cached = await fromRuntime(request);
196
527
  if (cached) {${staleVersionCode}
197
528
  return markFromCache(cached);
@@ -205,75 +536,84 @@ async function cacheFirst(event, request, maxEntries, maxAge) {
205
536
 
206
537
  const response = await _fetch(event, request);
207
538
  if (response.ok) {
208
- event.waitUntil(
209
- (async () => {
210
- await cacheResponse(request, response);
211
- _trim(CACHE_NAME_RUNTIME, maxEntries, maxAge);
212
- })(),
213
- );
539
+ const responseToCache = response.clone();
540
+ event.waitUntil(cacheResponse(request, responseToCache));
214
541
  }
215
542
  return response;
216
543
  }
217
544
 
218
- async function networkFirst(event, request, maxEntries, maxAge) {
545
+ async function networkFirst(event, request) {
219
546
  try {
220
547
  const response = await _fetch(event, request);
221
548
  if (response.ok) {
222
- event.waitUntil(
223
- (async () => {
224
- await cacheResponse(request, response);
225
- _trim(CACHE_NAME_RUNTIME, maxEntries, maxAge);
226
- })(),
227
- );
549
+ const responseToCache = response.clone();
550
+ event.waitUntil(cacheResponse(request, responseToCache));
228
551
  }
229
552
  return response;
230
553
  } catch {
231
554
  const cached = await fromRuntime(request);
232
- if (cached) return cached;
555
+ if (cached) {
556
+ return markFromCache(cached);
557
+ }
233
558
 
234
559
  const precached = await fromPrecache(request);
235
- if (precached) return precached;
560
+ if (precached) return markFromCache(precached);
236
561
 
237
562
  const fallback = await fromSpaFallback(request);
238
563
  if (fallback) return fallback;
239
564
 
240
- throw new Error("Request failed and no cached response available");
565
+ throw new Error("Network request failed and no cached response available");
241
566
  }
242
567
  }
243
568
 
244
- async function staleWhileRevalidate(event, request, maxEntries, maxAge) {
569
+ async function staleWhileRevalidate(event, request) {
245
570
  const cached = await fromRuntime(request);
246
571
  if (cached) {
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 response = await fetch(request);
268
- if (response.ok) {
269
- 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));
270
598
  }
271
- } catch {
272
- // Background refresh failed - stale cache remains usable
599
+ return markFromCache(cached);
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);
273
612
  }
613
+ return response;
274
614
  }
275
615
 
276
- async function cacheOnly(event, request, maxEntries, maxAge) {
616
+ async function cacheOnly(event, request) {
277
617
  const cached = await fromRuntime(request);
278
618
  if (cached) {${staleVersionCode}
279
619
  return markFromCache(cached);