@swoff/cli 0.3.6 → 0.3.7

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 (88) hide show
  1. package/README.md +375 -91
  2. package/bin/swoff +8 -14
  3. package/dist/__tests__/assemble-sw.test.js +2 -2
  4. package/dist/__tests__/assemble-sw.test.js.map +1 -1
  5. package/dist/__tests__/cli-integration.test.js +0 -1
  6. package/dist/__tests__/cli-integration.test.js.map +1 -1
  7. package/dist/__tests__/config-types.test.js +4 -3
  8. package/dist/__tests__/config-types.test.js.map +1 -1
  9. package/dist/__tests__/file-generators.test.js +12 -23
  10. package/dist/__tests__/file-generators.test.js.map +1 -1
  11. package/dist/__tests__/loader.test.js +0 -1
  12. package/dist/__tests__/loader.test.js.map +1 -1
  13. package/dist/__tests__/validator.test.js +0 -1
  14. package/dist/__tests__/validator.test.js.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/lib/commands/add.js +4 -4
  18. package/dist/lib/commands/add.js.map +1 -1
  19. package/dist/lib/commands/generate-guide.js +33 -6
  20. package/dist/lib/commands/generate-guide.js.map +1 -1
  21. package/dist/lib/commands/info.js +18 -4
  22. package/dist/lib/commands/info.js.map +1 -1
  23. package/dist/lib/commands/init.js +6 -2
  24. package/dist/lib/commands/init.js.map +1 -1
  25. package/dist/lib/generators/file-generators/auth-fetch.js +3 -3
  26. package/dist/lib/generators/file-generators/auth-fetch.js.map +1 -1
  27. package/dist/lib/generators/file-generators/auth-store.js +114 -5
  28. package/dist/lib/generators/file-generators/auth-store.js.map +1 -1
  29. package/dist/lib/generators/file-generators/auth-user.js +2 -2
  30. package/dist/lib/generators/file-generators/auth-user.js.map +1 -1
  31. package/dist/lib/generators/file-generators/cache.js +1 -3
  32. package/dist/lib/generators/file-generators/cache.js.map +1 -1
  33. package/dist/lib/generators/file-generators/client-injector.js +4 -16
  34. package/dist/lib/generators/file-generators/client-injector.js.map +1 -1
  35. package/dist/lib/generators/file-generators/fetch-wrapper.js +40 -15
  36. package/dist/lib/generators/file-generators/fetch-wrapper.js.map +1 -1
  37. package/dist/lib/generators/file-generators/generate-hooks.js +23 -209
  38. package/dist/lib/generators/file-generators/generate-hooks.js.map +1 -1
  39. package/dist/lib/generators/file-generators/guide-generator.js +153 -36
  40. package/dist/lib/generators/file-generators/guide-generator.js.map +1 -1
  41. package/dist/lib/generators/file-generators/mutation-queue.js +31 -3
  42. package/dist/lib/generators/file-generators/mutation-queue.js.map +1 -1
  43. package/dist/lib/generators/file-generators/push.js +143 -0
  44. package/dist/lib/generators/file-generators/push.js.map +1 -0
  45. package/dist/lib/generators/file-generators/quick-readme.js +13 -3
  46. package/dist/lib/generators/file-generators/quick-readme.js.map +1 -1
  47. package/dist/lib/generators/file-generators/sw-generator-build.js +1 -1
  48. package/dist/lib/generators/file-generators/sw-injector.js +1 -1
  49. package/dist/lib/generators/file-generators/sw-push.js +67 -0
  50. package/dist/lib/generators/file-generators/sw-push.js.map +1 -0
  51. package/dist/lib/generators/file-generators/sw-template.js +2 -0
  52. package/dist/lib/generators/file-generators/sw-template.js.map +1 -1
  53. package/dist/lib/generators/file-generators/type-definitions.js +13 -13
  54. package/dist/lib/generators/sw-generator.js +8 -9
  55. package/dist/lib/generators/sw-generator.js.map +1 -1
  56. package/dist/lib/generators/sw-sections/activate-handler.js +6 -2
  57. package/dist/lib/generators/sw-sections/activate-handler.js.map +1 -1
  58. package/dist/lib/generators/sw-sections/assemble-sw.js +5 -1
  59. package/dist/lib/generators/sw-sections/assemble-sw.js.map +1 -1
  60. package/dist/lib/generators/sw-sections/default-template.js +1 -30
  61. package/dist/lib/generators/sw-sections/default-template.js.map +1 -1
  62. package/dist/lib/generators/sw-sections/fetch-handler.js +157 -86
  63. package/dist/lib/generators/sw-sections/fetch-handler.js.map +1 -1
  64. package/dist/lib/generators/sw-sections/install-handler.js +12 -10
  65. package/dist/lib/generators/sw-sections/install-handler.js.map +1 -1
  66. package/dist/lib/generators/sw-sections/tag-management.js +11 -0
  67. package/dist/lib/generators/sw-sections/tag-management.js.map +1 -1
  68. package/dist/lib/generators/swoff-files-generator.js +9 -9
  69. package/dist/lib/generators/swoff-files-generator.js.map +1 -1
  70. package/dist/lib/shared/config-types.js +5 -3
  71. package/dist/lib/shared/config-types.js.map +1 -1
  72. package/package.json +3 -2
  73. package/templates/hooks/useAuth.jsx +30 -0
  74. package/templates/hooks/useAuth.tsx +30 -0
  75. package/templates/hooks/useBackgroundSync.jsx +31 -0
  76. package/templates/hooks/useBackgroundSync.tsx +31 -0
  77. package/templates/hooks/useCacheInvalidation.jsx +19 -0
  78. package/templates/hooks/useCacheInvalidation.tsx +19 -0
  79. package/templates/hooks/useCachedFetch.jsx +55 -0
  80. package/templates/hooks/useCachedFetch.tsx +69 -0
  81. package/templates/hooks/useMutationQueue.jsx +34 -0
  82. package/templates/hooks/useMutationQueue.tsx +34 -0
  83. package/templates/hooks/useNetworkStatus.jsx +19 -0
  84. package/templates/hooks/useNetworkStatus.tsx +19 -0
  85. package/templates/hooks/usePushSubscription.jsx +67 -0
  86. package/templates/hooks/usePushSubscription.tsx +67 -0
  87. package/templates/hooks/useSWUpdate.jsx +76 -0
  88. package/templates/hooks/useSWUpdate.tsx +76 -0
package/README.md CHANGED
@@ -9,6 +9,7 @@ Swoff generates a **service worker** and **client-side utilities** that give you
9
9
  - **Auth** — token management with automatic 401 handling
10
10
  - **Cache invalidation** — tag-based cache busting after mutations
11
11
  - **PWA** — install prompt and manifest
12
+ - **Push notifications** — subscription management with IndexedDB persistence and VAPID support
12
13
  - **Cross-tab sync** — broadcast changes across open tabs
13
14
  - **Background Sync API** — process mutations even after tab close
14
15
 
@@ -94,7 +95,7 @@ swoff add auth
94
95
  swoff add tag-invalidation
95
96
  swoff add cross-tab
96
97
  swoff add background-sync
97
- swoff add client-registration
98
+ swoff add push-notification
98
99
  ```
99
100
 
100
101
  ### `info`
@@ -123,23 +124,57 @@ This deletes `swoff/`, `swoff.config.json`, `version.json`, and removes the SW g
123
124
 
124
125
  ```json
125
126
  {
127
+ "$schema": "https://swoff.netlify.app/schema/v1.json",
128
+ "enabled": true,
126
129
  "framework": "react",
127
- "language": "ts",
128
- "buildCommand": "vite build",
130
+ "build": {
131
+ "outputDir": "dist",
132
+ "swFilename": "sw"
133
+ },
129
134
  "features": {
130
- "mutationQueue": true,
131
- "backgroundSync": false,
132
- "crossTabSync": false,
133
- "tagInvalidation": true,
134
- "clientRegistration": true,
135
135
  "pwa": {
136
- "enabled": false
136
+ "enabled": true,
137
+ "preventDefaultInstall": false
138
+ },
139
+ "serviceWorker": {
140
+ "version": {
141
+ "enabled": true,
142
+ "source": "from-package",
143
+ "minSupportedVersion": "1.0.0"
144
+ },
145
+ "autoUpdate": true,
146
+ "autoActivate": false,
147
+ "defaultStrategy": "cache-first",
148
+ "strategies": {
149
+ "/api/*": "network-first",
150
+ "/static/*": {
151
+ "strategy": "cache-first",
152
+ "maxCacheEntries": 50,
153
+ "maxCacheAge": 3600000
154
+ }
155
+ },
156
+ "cacheStrategy": "all",
157
+ "navigationPreload": true,
158
+ "maxCacheEntries": 100,
159
+ "maxCacheAge": 86400000,
160
+ "runtimeCacheName": "swoff-runtime",
161
+ "clearRuntimeOnUpdate": false,
162
+ "navigationMode": "spa",
163
+ "spaEntry": "/index.html"
137
164
  },
165
+ "mutationQueue": false,
166
+ "backgroundSync": false,
138
167
  "auth": {
139
- "enabled": true,
168
+ "enabled": false,
140
169
  "type": "bearer",
141
170
  "refreshPath": "/api/refresh",
142
171
  "userEndpoint": "/api/me"
172
+ },
173
+ "crossTabSync": true,
174
+ "tagInvalidation": true,
175
+ "pushNotifications": {
176
+ "enabled": false,
177
+ "vapidPublicKey": ""
143
178
  }
144
179
  }
145
180
  }
@@ -147,53 +182,106 @@ This deletes `swoff/`, `swoff.config.json`, `version.json`, and removes the SW g
147
182
 
148
183
  | Field | Type | Default | Description |
149
184
  |-------|------|---------|-------------|
185
+ | `$schema` | `string` | — | JSON Schema URL |
186
+ | `enabled` | `boolean` | `true` | Enable Swoff |
150
187
  | `framework` | `"react"` \| `"vue"` \| `"svelte"` \| `"vanilla"` | auto | Your UI framework |
151
- | `language` | `"ts"` \| `"js"` | auto | TypeScript or JavaScript output |
152
- | `buildCommand` | `string` | auto | Your build command (detected from `package.json`) |
188
+ | `build.outputDir` | `string` | `"dist"` | Build output directory |
189
+ | `build.swFilename` | `string` | `"sw"` | Service worker filename prefix |
190
+ | `features.pwa.enabled` | `boolean` | `true` | PWA install + manifest |
191
+ | `features.pwa.preventDefaultInstall` | `boolean` | `false` | Prevent default install prompt |
192
+ | `features.serviceWorker.version.enabled` | `boolean` | `true` | Enable versioned SW |
193
+ | `features.serviceWorker.version.source` | `"from-package"` \| `"manual"` | `"from-package"` | Version source |
194
+ | `features.serviceWorker.version.value` | `string` | — | Manual version (required if source is `"manual"`) |
195
+ | `features.serviceWorker.version.minSupportedVersion` | `string` | `"0.0.0"` | Min supported SW version |
196
+ | `features.serviceWorker.autoUpdate` | `boolean` | `true` | Auto-update SW |
197
+ | `features.serviceWorker.autoActivate` | `boolean` | `false` | Auto-activate SW |
198
+ | `features.serviceWorker.defaultStrategy` | `string` | `"cache-first"` | Default cache strategy |
199
+ | `features.serviceWorker.strategies` | `object` | `{}` | Per-route caching strategies. Each value is a strategy name string, or `{ strategy, maxCacheEntries?, maxCacheAge? }` |
200
+ | `features.serviceWorker.cacheStrategy` | `"all"` \| `"explicit-only"` | `"all"` | When to apply caching strategies. `"all"`: every GET/HEAD; `"explicit-only"`: only if `X-SW-Cache-Strategy` header is present |
201
+ | `features.serviceWorker.navigationPreload` | `boolean` | `true` | Enable Navigation Preload API — reduces SW startup latency for navigation requests |
202
+ | `features.serviceWorker.maxCacheEntries` | `number` | — | Max runtime cache entries (0 = unlimited) |
203
+ | `features.serviceWorker.maxCacheAge` | `number` | — | Max runtime cache age in ms (0 = unlimited) |
204
+ | `features.serviceWorker.runtimeCacheName` | `string` | `"swoff-runtime"` | Runtime cache name |
205
+ | `features.serviceWorker.clearRuntimeOnUpdate` | `boolean` | `false` | Clear runtime cache on update |
206
+ | `features.serviceWorker.navigationMode` | `"spa"` \| `"default"` | `"spa"` | Navigation caching mode |
207
+ | `features.serviceWorker.spaEntry` | `string` | `"/index.html"` | SPA entry for nav fallback |
153
208
  | `features.mutationQueue` | `boolean` | `false` | Offline write queue |
154
209
  | `features.backgroundSync` | `boolean` | `false` | Background Sync API |
155
- | `features.crossTabSync` | `boolean` | `false` | Cross-tab broadcast |
156
- | `features.tagInvalidation` | `boolean` | `false` | Tag-based cache invalidation |
157
- | `features.clientRegistration` | `boolean` | `false` | SW registration lifecycle |
158
- | `features.pwa.enabled` | `boolean` | `false` | PWA install + manifest |
159
210
  | `features.auth.enabled` | `boolean` | `false` | Auth module |
160
- | `features.auth.type` | `"bearer"` \| `"cookie"` \| `"custom"` | | Auth strategy |
211
+ | `features.auth.type` | `"bearer"` \| `"cookie"` \| `"custom"` | `"bearer"` | Auth strategy |
161
212
  | `features.auth.refreshPath` | `string` | `"/api/refresh"` | Token refresh endpoint |
162
213
  | `features.auth.userEndpoint` | `string` | `"/api/me"` | Current user endpoint |
214
+ | `features.crossTabSync` | `boolean` | `false` | Cross-tab broadcast |
215
+ | `features.tagInvalidation` | `boolean` | `false` | Tag-based cache invalidation |
216
+ | `features.pushNotifications.enabled` | `boolean` | `false` | Push notification subscription management |
217
+ | `features.pushNotifications.vapidPublicKey` | `string` | `""` | VAPID public key (can also be passed at runtime) |
163
218
 
164
219
  ---
165
220
 
166
- ## Generated File Structure
221
+ ## Generated Files
167
222
 
168
223
  ```
169
224
  swoff/
170
- ├── client-injector.ts # Single entry point import and call initServiceWorker()
225
+ ├── client-injector.ts # Orchestratorwires all features together
226
+ │ Imports: sw/injector, pwa/install, mutation-queue
227
+ │ Exports: initServiceWorker()
228
+ │ Always generated
229
+
171
230
  ├── fetch-wrapper.ts # Unified fetch with caching, auth, offline queue
172
- ├── cache.ts # Low-level cache invalidation (tagInvalidation)
173
- ├── invalidation-tags.ts # Tag generation helpers (tagInvalidation)
174
- ├── mutation-queue.ts # Offline write queue (mutationQueue)
175
- ├── background-sync.ts # Background Sync API (backgroundSync)
176
- ├── swoff.d.ts # TypeScript declarations
231
+ │ Imports: invalidation-tags, cache, auth/store, mutation-queue (conditional)
232
+ Exports: fetchWithCache(input, options?)
233
+ │ Always generated
234
+
235
+ ├── cache.ts # Low-level cache invalidation
236
+ │ Exports: invalidateByTag(), invalidateByTags()
237
+ │ Generated when: tagInvalidation is true
177
238
 
178
- ├── auth/
179
- ├── store.ts # Auth token/user persistence
180
- ├── fetch.ts # Authenticated fetch wrapper
239
+ ├── invalidation-tags.ts # Tag generation helpers
240
+ Exports: generateTags(), invalidateUrl(), invalidateByMethod()
241
+ Generated when: tagInvalidation is true
242
+
243
+ ├── mutation-queue.ts # Offline write queue
244
+ │ Exports: queueMutation(), processMutationQueue(), flushMutations(), getPendingCount()
245
+ │ Generated when: mutationQueue is true
246
+
247
+ ├── push.ts # Push notification subscription management
248
+ │ Exports: subscribeToPush(), unsubscribeFromPush(), isSubscribed(), getPushSubscription(), requestNotificationPermission()
249
+ │ Generated when: pushNotifications.enabled is true
250
+
251
+ ├── background-sync.ts # Background Sync API
252
+ │ Exports: syncWhenPossible(), retrySync()
253
+ │ Generated when: backgroundSync is true
254
+
255
+ ├── swoff.d.ts # TypeScript declarations for all generated modules
256
+
257
+ ├── auth/ # Generated when auth.enabled is true
258
+ │ ├── store.ts # Token/user persistence (memory + IndexedDB) + auth header helpers
259
+ │ │ Exports: setAuth(), getAuth(), clearAuth(), isAuthValid(), createAuthFromResponse(), ensureValidAuth(), withAuthHeaders()
181
260
  │ ├── user.ts # User data caching
261
+ │ │ Exports: fetchCurrentUser(), getCachedUser(), cacheUser(), clearCachedUser()
182
262
  │ └── state.ts # Online/offline × auth state detection
263
+ │ Exports: getAuthState()
183
264
 
184
- ├── hooks/ # React hooks (if framework = react)
185
- │ ├── useAuth.tsx
186
- │ ├── useCachedFetch.tsx
187
- │ ├── useMutationQueue.tsx
188
- └── usePWAUpdate.tsx
265
+ ├── hooks/ # Generated when framework is "react"
266
+ │ ├── useAuth.tsx # Reactive auth + connectivity state
267
+ │ ├── useCachedFetch.tsx # Auto-refetch on cache invalidation
268
+ │ ├── useMutationQueue.tsx # Queue status and sync results
269
+ ├── useSWUpdate.tsx # SW update management (+ useSWProgress in same file)
270
+ │ ├── useNetworkStatus.tsx # Reactive online/offline state
271
+ │ ├── useBackgroundSync.tsx # Background sync state and trigger
272
+ │ └── useCacheInvalidation.tsx # Cache invalidation helpers
189
273
 
190
274
  ├── pwa/
191
- │ └── install.ts
275
+ │ └── install.ts # PWA install prompt handling
276
+ │ Exports: setupPwaInstall(), isInstallable(), promptInstall()
277
+ │ Generated when: pwa.enabled is true
192
278
 
193
279
  ├── sw/
194
- │ ├── template.js # Service worker source
280
+ │ ├── template.js # Service worker source (runs in SW scope)
281
+ │ │ # Implements all 5 caching strategies in the SW
195
282
  │ ├── injector.ts # SW registration logic
196
- └── generator.js # Build-time SW generator
283
+ # Exports: initServiceWorker(), handleUpdateApproved(), skipWaiting()
284
+ │ └── generator.js # Build-time script — embeds asset hashes into SW
197
285
 
198
286
  ├── manifest.json
199
287
  ├── GUIDE.md # Full integration walkthrough
@@ -202,72 +290,217 @@ swoff/
202
290
 
203
291
  ---
204
292
 
205
- ## Feature Deep-Dive
293
+ ## Cache Strategies
206
294
 
207
- ### Mutation Queue
295
+ The service worker applies a caching strategy to GET/HEAD requests based on `features.serviceWorker.cacheStrategy`:
208
296
 
209
- When the user is offline, write operations (POST/PUT/DELETE) are stored in IndexedDB. They replay automatically when the connection returns.
297
+ | Mode | Behavior |
298
+ |------|----------|
299
+ | `"all"` (default) | All GET/HEAD requests go through the strategy system. Plain `fetch()` calls are cached by the SW just like `fetchWithCache()` calls. |
300
+ | `"explicit-only"` | Only requests with a `X-SW-Cache-Strategy` header are processed by the SW strategy system. Plain `fetch()` calls pass through the SW unmodified. `fetchWithCache()` sets this header automatically — use it for all API calls to ensure caching works. |
210
301
 
211
- ```js
212
- import { queueMutation, getPendingCount } from "./swoff/mutation-queue.js";
302
+ ### Strategy resolution (3 tiers, highest to lowest priority)
213
303
 
214
- // Offline writestored and replayed when online
215
- await queueMutation({
216
- method: "POST",
217
- url: "/api/todos",
218
- body: { title: "Buy milk" },
219
- tags: ["todos"],
220
- });
304
+ 1. **Per-request override**set `strategy` or `staleWhileRevalidate` on any `fetchWithCache(options)`. Sends `X-SW-Strategy` header to the SW.
305
+ 2. **URL pattern match** — `features.serviceWorker.strategies` object maps URL prefixes (e.g. `/api/*`) to strategies.
306
+ 3. **Default** — `features.serviceWorker.defaultStrategy` (default: `"cache-first"`).
307
+
308
+ ### Available strategies
309
+
310
+ | Strategy | Behavior | Best for |
311
+ |----------|----------|----------|
312
+ | `cache-first` | Return cached if available, else fetch + cache. Default. | Static assets, images, fonts, rarely-changing data |
313
+ | `network-first` | Try network. On success, cache. On failure, serve cache. | API endpoints, dynamic content where freshness matters |
314
+ | `stale-while-revalidate` | Return cached immediately, refresh cache in background. | Fast UI, non-critical data, content that can be slightly stale |
315
+ | `cache-only` | Serve from cache only. Returns 404 if missing. | Offline-critical assets that must always be available |
316
+ | `network-only` | Always fetch, never cache. | Sensitive or real-time data, payment flows |
317
+
318
+ ### Request dispatch flow
319
+
320
+ Every GET/HEAD request goes through this flow in the SW:
221
321
 
222
- // Show sync badge
223
- const count = await getPendingCount();
224
322
  ```
323
+ navigation (SPA fallback) → precache hit? → strategy dispatch → pass-through
324
+ ```
325
+
326
+ - **Navigation requests** (SPA mode): if no cached response is found, the SPA entry (`/index.html` by default) is served as a fallback.
327
+ - **Precache**: build assets cached at install time are checked first.
328
+ - **Strategy**: the resolved strategy (via the 3-tier priority above) determines how the request is cached and served.
329
+ - **Pass-through**: if no strategy matches, the request goes to the network.
225
330
 
226
- The queue processes automatically on the `online` event. Call `flushMutations()` after re-login to retry mutations that failed due to auth expiry.
331
+ ---
227
332
 
228
- ### Fetch Wrapper
333
+ ## fetchWithCache API
229
334
 
230
- Drop-in replacement for `fetch()` that communicates with the service worker about caching strategy.
335
+ A drop-in replacement for `fetch()` that communicates with the service worker about caching, handles offline mode, deduplicates in-flight requests, auto-generates cache tags, and auto-invalidates after mutations.
231
336
 
232
337
  ```js
233
338
  import { fetchWithCache } from "./swoff/fetch-wrapper.js";
234
339
 
235
- // Reads are cached for offline access
340
+ // Read cached by the SW for offline access
236
341
  const { response, fromCache } = await fetchWithCache("/api/todos");
237
342
  const data = await response.json();
238
343
 
239
- // Writes auto-invalidate related cache tags
344
+ // Mutation — passes through, auto-invalidates cache tags
240
345
  await fetchWithCache("/api/todos", {
241
346
  method: "POST",
242
347
  body: JSON.stringify({ title: "New task" }),
243
348
  });
244
349
 
245
- // Auth requests
246
- const { response: user } = await fetchWithCache("/api/me", { auth: true });
350
+ // POST used as a read (search, GraphQL) — override with type: "read"
351
+ await fetchWithCache("/api/search", {
352
+ method: "POST",
353
+ type: "read",
354
+ body: JSON.stringify({ query: "hello" }),
355
+ });
356
+
357
+ // With cache tags + stale-while-revalidate
358
+ const { response: staleRes, fromCache } = await fetchWithCache("/api/data", {
359
+ tags: ["data"],
360
+ staleWhileRevalidate: true,
361
+ });
362
+
363
+ // Auth + disable offline queue
364
+ const { response: userRes } = await fetchWithCache("/api/me", {
365
+ auth: true,
366
+ queueOffline: false,
367
+ });
368
+ ```
369
+
370
+ ### Options
371
+
372
+ All `RequestInit` fields are supported (`method`, `body`, `headers`, `credentials`, `signal`, etc.), plus:
373
+
374
+ | Option | Type | Default | Description |
375
+ |--------|------|---------|-------------|
376
+ | `tags` | `string[]` | auto-generated | Cache invalidation tags for this request |
377
+ | `staleWhileRevalidate` | `boolean` | `false` | Return cached immediately, refresh in background |
378
+ | `auth` | `boolean` | `false` | Attach auth token (uses `getAuth()`) |
379
+ | `queueOffline` | `boolean` | `true` | When offline, queue writes to IndexedDB for later replay |
380
+ | `invalidate` | `'auto' \| string[] \| false` | `'auto'` | Auto-invalidate cache tags after a successful mutation |
381
+ | `type` | `'read' \| 'mutation'` | auto-detected | Override read/mutation detection |
382
+ | `strategy` | `'cache-first' \| 'network-first' \| 'stale-while-revalidate' \| 'cache-only' \| 'network-only'` | — | Override caching strategy per-request (highest priority, overrides config strategies and default) |
383
+
384
+ ### Behavior
385
+
386
+ - **Read vs mutation**: GET/HEAD → read (cached). POST/PUT/DELETE/PATCH → mutation (pass through). Override with `type: 'read'` or `type: 'mutation'`.
387
+ - **Offline reads**: returns cached response if available, throws if not cached.
388
+ - **Offline writes**: queues to IndexedDB (when `mutationQueue` enabled). Replays on `online` event. Disable per-request with `queueOffline: false`.
389
+ - **Dedup**: in-flight GETs to the same URL return a single promise (cloned response).
390
+ - **Auto-tags**: when `tagInvalidation` is enabled, tags are derived from the URL for read requests.
391
+ - **Auto-invalidate**: after a successful mutation, matching cache tags are invalidated so the SW re-fetches fresh data.
392
+ - **Auth**: when `auth: true`, attaches auth headers via `withAuthHeaders()` (supports bearer, cookie, and custom). Dispatches `sw-auth-unauthorized` on 401 and clears auth.
393
+
394
+ ### Return value
395
+
396
+ ```
397
+ { response: Response, fromCache: boolean }
247
398
  ```
248
399
 
249
- Returns `{ response: Response, fromCache: boolean }`.
400
+ - `response`: the fetch Response (from cache or network)
401
+ - `fromCache`: `true` when the response was served from cache
250
402
 
251
- ### Auth
403
+ In TypeScript, use the generic variant for typed responses:
252
404
 
253
- Token-based authentication with memory-only tokens and automatic 401 handling.
405
+ ```ts
406
+ const { response } = await fetchWithCache<Todo[]>("/api/todos");
407
+ const data: Todo[] = await response.json();
408
+ // data is typed as Todo[]
409
+ ```
410
+
411
+ ---
412
+
413
+ ## Auth API
414
+
415
+ The auth module is generated when `features.auth.enabled` is `true`. It manages authentication state with a **memory-only token** (never persisted to disk) and optional IndexedDB caching for offline user display.
416
+
417
+ There is no separate auth fetch wrapper — `fetchWithCache` handles all auth types
418
+ natively. Just pass `{ auth: true }`:
254
419
 
255
420
  ```js
256
- import { authenticatedFetch } from "./swoff/auth/fetch.js";
421
+ import { fetchWithCache } from "./swoff/fetch-wrapper.js";
257
422
 
258
- // Attaches Bearer token, handles 401
259
- const user = await authenticatedFetch("/api/me").then((r) => r.json());
423
+ // Attaches auth headers, bypasses SW cache for auth URLs, handles 401
424
+ const { response } = await fetchWithCache("/api/me", { auth: true });
425
+ const user = await response.json();
260
426
 
261
- // For mutations too — no separate wrapper needed
262
- await authenticatedFetch("/api/todos", {
427
+ // Mutations too
428
+ await fetchWithCache("/api/todos", {
263
429
  method: "POST",
430
+ auth: true,
264
431
  body: JSON.stringify({ title: "New" }),
265
432
  });
266
433
  ```
267
434
 
268
- Auth endpoints (`/login`, `/logout`, `/register`) automatically bypass the SW cache. On 401, `sw-auth-unauthorized` is dispatched and the token is cleared.
435
+ **What `auth: true` does:**
436
+
437
+ | Step | What happens |
438
+ |------|-------------|
439
+ | 1 | Calls `getAuth()` to retrieve the stored token/user |
440
+ | 2 | Calls `withAuthHeaders(headers, auth)` — injects Bearer token, cookie, or custom header based on `auth.type` in config |
441
+ | 3 | Marks auth endpoints (`/login`, `/logout`, `/register`, `refreshPath`, `userEndpoint`) as `"mutation"` strategy so the SW never caches them |
442
+ | 4 | For `auth.type: "cookie"`, sets `credentials: "include"` |
443
+ | 5 | On 401 response: calls `clearAuth()`, dispatches `sw-auth-unauthorized` event |
444
+
445
+ **Returns:** `{ response: Response, fromCache: boolean }` — same as any `fetchWithCache` call
446
+
447
+ ### Auth functions
448
+
449
+ | Function | Arguments | Returns | Description |
450
+ |----------|-----------|---------|-------------|
451
+ | `setAuth(authData)` | `{ token?, user?, expiresAt? }` | `Promise<void>` | Store auth in memory, persist user to IndexedDB |
452
+ | `getAuth()` | — | `Promise<AuthData \| null>` | Retrieve auth from memory (or IndexedDB fallback after page refresh) |
453
+ | `clearAuth()` | — | `Promise<void>` | Clear memory + IndexedDB. Call on logout/401 |
454
+ | `isAuthValid(auth)` | `AuthData \| null` | `boolean` | Check existence + expiry (`expiresAt`). Returns `true` if no `expiresAt` set |
455
+ | `createAuthFromResponse(response)` | server login response | `AuthData` | **Edit this** to match your backend's login response shape |
456
+ | `ensureValidAuth()` | — | `Promise<AuthData \| null>` | Check expiry, refresh token via `refreshPath` if needed |
457
+ | `fetchCurrentUser()` | — | `Promise<Record<string, unknown> \| null>` | Fetch from `userEndpoint` and cache in IndexedDB |
458
+ | `getCachedUser()` | — | `Promise<Record<string, unknown> \| null>` | Load user from IndexedDB (available offline) |
459
+ | `cacheUser(user)` | user object | `Promise<void>` | Manually persist user data |
460
+ | `clearCachedUser()` | — | `Promise<void>` | Remove user from cache |
461
+ | `getAuthState()` | — | `Promise<{ authenticated, user, online }>` | Detect which of 4 states the app is in |
462
+
463
+ ### Auth types
464
+
465
+ | `auth.type` | How auth headers are set | Notes |
466
+ |-------------|------------------------|-------|
467
+ | `"bearer"` | `Authorization: Bearer <token>` | Token in memory only. Re-login required after page refresh. Use `refreshPath` for token refresh. |
468
+ | `"cookie"` | No explicit header. `credentials: "include"` is set for all requests. | HttpOnly cookie handled by the server. |
469
+ | `"custom"` | **Edit the `withAuthHeaders` function** in `auth/store.ts` | Full control over header injection. |
470
+
471
+ ---
472
+
473
+ ## Mutation Queue
474
+
475
+ When the user is offline, write operations (POST/PUT/DELETE) are stored in IndexedDB. They replay automatically when the connection returns.
476
+
477
+ ```js
478
+ import { queueMutation, getPendingCount } from "./swoff/mutation-queue.js";
479
+
480
+ // Offline write — stored and replayed when online
481
+ await queueMutation({
482
+ method: "POST",
483
+ url: "/api/todos",
484
+ body: { title: "Buy milk" },
485
+ tags: ["todos"],
486
+ });
487
+
488
+ // Show sync badge
489
+ const count = await getPendingCount();
490
+ ```
491
+
492
+ | Function | Description |
493
+ |----------|-------------|
494
+ | `queueMutation(mutation)` | Store a write for later sync |
495
+ | `processMutationQueue()` | Replay all queued writes. Runs automatically on `online` event |
496
+ | `flushMutations()` | Same as `processMutationQueue`. Call after re-login (queued mutations may have stale auth) |
497
+ | `getPendingCount()` | Number of mutations waiting to sync |
498
+
499
+ Generated when `features.mutationQueue` is `true`.
269
500
 
270
- ### Cache Invalidation
501
+ ---
502
+
503
+ ## Cache Invalidation
271
504
 
272
505
  Tag-based invalidation keeps the service worker cache fresh after mutations.
273
506
 
@@ -285,7 +518,18 @@ await invalidateUrl("/api/todos/42");
285
518
 
286
519
  When the SW receives an invalidation, it removes matching cache entries and attempts to background-refetch them. If the refetch fails (network error), the old cached entry is served with a stale-while-revalidate fallback.
287
520
 
288
- ### PWA
521
+ | Function | Description |
522
+ |----------|-------------|
523
+ | `generateTags(url)` | Extract tags from URL path. e.g. `/api/todos/42` → `["todos", "todo:42"]` |
524
+ | `invalidateUrl(url)` | Extract tags from URL and invalidate all matching cache entries |
525
+ | `invalidateByTag(tag)` | Invalidate a single tag. Dispatches `cache-invalidated` event |
526
+ | `invalidateByTags(tags)` | Invalidate multiple tags |
527
+
528
+ Generated when `features.tagInvalidation` is `true`.
529
+
530
+ ---
531
+
532
+ ## PWA
289
533
 
290
534
  Install prompt handling and manifest generation.
291
535
 
@@ -298,30 +542,77 @@ if (isInstallable()) {
298
542
  }
299
543
  ```
300
544
 
301
- ### React Hooks
545
+ | Function | Description |
546
+ |----------|-------------|
547
+ | `setupPwaInstall()` | Listen for `beforeinstallprompt`/`appinstalled` events. Called automatically by `client-injector.ts` |
548
+ | `isInstallable()` | Check if install prompt is available |
549
+ | `promptInstall()` | Show the native install prompt. Returns `{ outcome }` |
550
+
551
+ Generated when `features.pwa.enabled` is `true`.
552
+
553
+ ---
554
+
555
+ ## Push Notifications
302
556
 
303
- When `framework` is `"react"`, generated hooks provide reactive state:
557
+ Push notification subscription management with IndexedDB persistence.
558
+
559
+ ```js
560
+ import { subscribeToPush, unsubscribeFromPush, isSubscribed } from "./swoff/push.js";
561
+
562
+ // Subscribe (triggers permission prompt)
563
+ const sub = await subscribeToPush("YOUR_VAPID_PUBLIC_KEY");
564
+ if (sub) {
565
+ await fetch("/api/push/subscribe", {
566
+ method: "POST",
567
+ body: JSON.stringify(sub.toJSON()),
568
+ });
569
+ }
570
+
571
+ // Unsubscribe
572
+ await unsubscribeFromPush();
573
+ ```
574
+
575
+ | Function | Description |
576
+ |----------|-------------|
577
+ | `subscribeToPush(vapidPublicKey)` | Request permission and subscribe. Returns `PushSubscription` or `null` if denied |
578
+ | `unsubscribeFromPush()` | Unsubscribe and clear stored subscription from IndexedDB |
579
+ | `isSubscribed()` | Check if currently subscribed |
580
+ | `getPushSubscription()` | Get current `PushSubscription` object, or `null` |
581
+ | `requestNotificationPermission()` | Request notification permission only. Returns `boolean` |
582
+
583
+ Generated when `features.pushNotifications.enabled` is `true`.
584
+
585
+ ---
586
+
587
+ ## React Hooks
588
+
589
+ When `features.framework` is `"react"`, generated hooks provide reactive state:
304
590
 
305
591
  | Hook | Returns | Description |
306
592
  |------|---------|-------------|
307
- | `useAuth()` | `{ authenticated, user, online }` | Auth + connectivity state |
308
- | `useCachedFetch(url, options?)` | `{ data, error, loading, refetch }` | Auto-refetches on tag invalidation |
309
- | `useMutationQueue()` | `{ pending, lastSync }` | Queue status and sync results |
310
- | `usePWAUpdate()` | `{ updateStatus, progress, forceUpdate, acceptUpdate, dismissUpdate }` | SW update management |
311
- | `useSWProgress()` | `{ status, progress }` | Download progress during SW update |
593
+ | `useAuth()` | `{ authenticated, user, online }` | Auth + connectivity state. Listens to online/offline/auth changes |
594
+ | `useCachedFetch(url, options?)` | `{ data, error, loading, refetch }` | Fetches data (auto-parsed JSON), auto-refetches on tag invalidation events |
595
+ | `useMutationQueue()` | `{ pending, lastSync }` | Queue status (`pending` count) and last sync result (`lastSync.succeeded`, `lastSync.failed`) |
596
+ | `useSWUpdate()` | `{ updateStatus, currentVersion, availableVersion, forceUpdate, error, acceptUpdate, dismissUpdate }` | SW update management. `updateStatus` is one of `"idle"`, `"available"`, `"downloading"`, `"ready"` |
597
+ | `useSWProgress()` | `{ status, progress }` | Download progress during SW update. `progress` is `{ percent, downloaded, total }` |
598
+ | `usePushSubscription(vapidPublicKey)` | `{ subscribed, subscription, permission, loading, subscribe, unsubscribe }` | Push subscription state. `subscribe()`/`unsubscribe()` toggle notifications at runtime |
599
+ | `useNetworkStatus()` | `boolean` | Reactive online/offline state |
600
+ | `useBackgroundSync()` | `{ supported, registered, lastSync, triggerSync }` | Background sync support detection and manual sync trigger |
601
+ | `useCacheInvalidation()` | `{ invalidateByTag, invalidateByTags, invalidateUrl }` | Stable callback wrappers around cache invalidation functions |
312
602
 
313
603
  ```tsx
314
604
  import { useCachedFetch } from "./swoff/hooks/useCachedFetch.tsx";
315
605
 
316
- function TodosList() {
606
+ function Todos() {
317
607
  const { data, loading } = useCachedFetch("/api/todos");
318
-
319
608
  if (loading) return <Spinner />;
320
609
  return <TodoList data={data} />;
321
610
  }
322
611
  ```
323
612
 
324
- ### Build Script
613
+ ---
614
+
615
+ ## Build Script
325
616
 
326
617
  After `swoff generate`, your `package.json` build script is updated to:
327
618
 
@@ -329,22 +620,15 @@ After `swoff generate`, your `package.json` build script is updated to:
329
620
  "build": "vite build && node swoff/sw/generator.js"
330
621
  ```
331
622
 
332
- The generator learns your build output and precaches all assets in the service worker. Running `swoff clean` removes this suffix.
333
-
334
- ---
335
-
336
- ## Service Worker Architecture
337
-
338
- The generated service worker (`sw/template.js`) implements:
623
+ The generator (`sw/generator.js`) runs after every build:
339
624
 
340
- - **Cache strategies**: network-first for API routes, cache-first for assets
341
- - **Tag invalidation**: removes stale cache entries and background-refetches
342
- - **Stale-while-revalidate**: on refetch failure, serves cached content with a stale indicator
343
- - **Offline fallback**: returns cached responses when the network is unavailable
344
- - **Cross-tab sync**: forwards invalidation events to all open clients
345
- - **PWA lifecycle**: skip-waiting, activate, and install event handling
625
+ 1. Reads `swoff.config.json` for output dir, version, and strategy config
626
+ 2. Reads `sw/template.js` the service worker source
627
+ 3. Collects all built assets from the output directory
628
+ 4. Replaces placeholders (`[[CACHE_NAME]]`, `[[ASSETS_LIST]]`, `[[AUTO_SKIP_WAITING]]`) with actual values
629
+ 5. Writes the final versioned SW file (e.g. `dist/sw-v1.2.3.js`) and `version.json`
346
630
 
347
- The build-time generator (`sw/generator.js`) collects your built asset files and embeds their hashes into the service worker, so cached assets are versioned and updated when they change.
631
+ Running `swoff clean` removes the generator suffix from your build script.
348
632
 
349
633
  ---
350
634
 
package/bin/swoff CHANGED
@@ -1,24 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from 'child_process';
4
- import { existsSync } from 'fs';
5
- import { dirname, join } from 'path';
6
- import { fileURLToPath } from 'url';
3
+ import { existsSync } from 'node:fs';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
7
6
 
8
- const binDir = dirname(fileURLToPath(import.meta.url));
9
- const distDir = join(binDir, '..', 'dist');
10
- const entryPoint = join(distDir, 'index.js');
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+ const entryPoint = join(__dirname, '..', 'dist', 'index.js');
11
9
 
12
10
  if (!existsSync(entryPoint)) {
13
11
  console.error('Error: CLI not built. Run: npm run build');
14
12
  process.exit(1);
15
13
  }
16
14
 
17
- const proc = spawn('node', [entryPoint, ...process.argv.slice(2)], {
18
- stdio: 'inherit',
19
- cwd: process.cwd(),
20
- });
21
-
22
- proc.on('exit', (code) => {
23
- process.exit(code || 0);
15
+ import(entryPoint).catch((err) => {
16
+ console.error('Error:', err instanceof Error ? err.message : String(err));
17
+ process.exit(1);
24
18
  });
@@ -9,7 +9,7 @@ describe("assembleSW", () => {
9
9
  const sw = assembleSW(config, "1.0.0");
10
10
  expect(sw).toContain("CACHE_NAME = 'sw-v1.0.0'");
11
11
  expect(sw).toContain("self.addEventListener");
12
- expect(sw).toContain("SWOFF");
12
+ expect(sw).toContain("CACHE_NAME_RUNTIME");
13
13
  });
14
14
  it("includes config header with resolved version", () => {
15
15
  const sw = assembleSW(config, "2.5.0");
@@ -41,7 +41,7 @@ describe("assembleSW", () => {
41
41
  const sw = assembleSW(config, "1.0.0");
42
42
  expect(sw).toContain("fromPrecache");
43
43
  expect(sw).toContain("async function fromPrecache");
44
- expect(sw).toContain("cache.match(request)");
44
+ expect(sw).toContain("return cache.match(new URL");
45
45
  expect(sw).toContain("precached = await fromPrecache(request)");
46
46
  });
47
47
  it("includes message handler", () => {