@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
@@ -0,0 +1,374 @@
1
+ # Architecture
2
+
3
+ Design decisions and rationale behind Swoff's architecture.
4
+
5
+ ---
6
+
7
+ ## Why a service worker + client hybrid?
8
+
9
+ Swoff splits responsibilities between two scopes:
10
+
11
+ **Service worker (SW) scope** — runs in a separate thread, survives page navigation, has access to Cache Storage API:
12
+ - Intercepts fetch requests and applies caching strategies
13
+ - Manages precached assets at install time
14
+ - Maintains persistent SSE/WebSocket connections for server push
15
+ - Handles push notification events
16
+ - Broadcasts invalidation across clients (cross-tab sync)
17
+ - Serves cached responses when offline
18
+
19
+ **Client (window) scope** — runs in the page, has access to DOM, IndexedDB, and React hooks:
20
+ - Tracks mutation state per-operation
21
+ - Queues offline writes to IndexedDB (the SW could also write, but client-side queuing keeps mutation state accessible to UI components and avoids SW lifecycle complexity)
22
+ - Provides reactive hooks (`useCachedFetch`, `useMutationQueue`)
23
+ - Manages auth tokens in memory-only storage (never exposed to SW)
24
+ - Detects online/offline state and triggers mutation replay
25
+
26
+ The hybrid model exists because:
27
+ - **Stale detection** requires the SW to know about cached responses (SW scope)
28
+ - **Background refresh** requires the SW to initiate fetches without page involvement (SW scope)
29
+ - **Mutation state tracking** needs per-component reactivity (client scope)
30
+ - **IndexedDB writes** for offline queue need to survive page navigation (SW could work for reads, but client writes are simpler and more reliable)
31
+
32
+ ---
33
+
34
+ ## Reactive strategy: staleTime + refresh triggers
35
+
36
+ Swoff's staleness model is different from a TTL eviction. It is scoped to a single explicit **reactive** strategy rather than spread across all strategies:
37
+
38
+ - **Fresh**: within `staleTime` seconds of being cached. The SW serves from cache immediately — no network request.
39
+ - **Stale**: past `staleTime`. The SW still serves the cached response, but triggers a **background refresh** (batched and rate-limited). The next request gets fresh data.
40
+
41
+ This means the user **never sees a loading spinner** for cached data. The response is always instant from cache; freshness is maintained in the background.
42
+
43
+ **staleTime is reactive-only.** It has no meaning on other strategies — each non-reactive strategy has its own simple, predictable contract:
44
+
45
+ | Strategy | Behavior |
46
+ |----------|----------|
47
+ | `cache-first` | Serve from cache if available, fall back to network. No staleTime awareness. |
48
+ | `network-first` | Try network first, fall back to cache on failure. No staleTime awareness. |
49
+ | `stale-while-revalidate` | Serve from cache immediately + always background refresh on every request. Unconditional — no staleTime gate. |
50
+ | `cache-only` | Serve from cache only. Never hits the network. |
51
+ | `network-only` | Always hit the network. Never caches. |
52
+ | `reactive` | Serve from cache. If stale (past `staleTime`), trigger a background refresh. Additionally, can auto-refetch on interval, window focus, or reconnect. |
53
+
54
+ The key insight: staleTime does not **evict** the entry. It makes the data **usable indefinitely** while keeping it fresh in the background. Background refreshes are batched (`features.refetchQueue.batchSize`) with rate limiting (`features.refetchQueue.batchDelayMs`) to avoid stampedes.
55
+
56
+ The reactive strategy bundles three optional refresh triggers that all gate through `staleTime`:
57
+
58
+ | Trigger | Description | Gating |
59
+ |---------|-------------|--------|
60
+ | `refetchInterval` | Timer-based — re-fetches every N seconds in the background | Only fires if stale (past `staleTime`) |
61
+ | `refetchOnFocus` | Fires when the tab regains focus (`visibilitychange`) | Only fires if stale (past `staleTime`) |
62
+ | `refetchOnReconnect` | Fires when the browser comes back online | Only fires if stale (past `staleTime`) |
63
+
64
+ ---
65
+
66
+ ## Batch refresh queue (`queueRefresh`)
67
+
68
+ Instead of fire-and-forget `event.waitUntil(refetch())` on every stale request, Swoff uses a shared batch queue as the single deduped entry point for all proactive refreshes:
69
+
70
+ 1. Any trigger (staleTime expiry, refetchInterval tick, refetchOnFocus, refetchOnReconnect, or tag invalidation) calls `queueRefresh(url)` which adds the URL to a `Map<string, entry>` keyed by cache key (automatic dedup)
71
+ 2. A single `_processRefreshQueue()` microtask processes URLs in batches of `features.refetchQueue.batchSize`
72
+ 3. Between batches, a delay of `features.refetchQueue.batchDelayMs` is applied (rate limiting)
73
+ 4. On each successful refetch, `CACHE_UPDATED` is posted to all connected clients, and any `staleVersions` tracking is cleaned up
74
+
75
+ **Retry with exponential backoff**: if a refresh fetch fails (network error, non-ok response), the entry is re-queued with an incremented `retryCount`. The retry delay = `features.refetchQueue.retryDelayMs × 2^(retryCount - 1)`. After `features.refetchQueue.maxRetries` consecutive failures, the entry is dropped. A `setTimeout` schedules re-processing after the delay, ensuring the SW stays alive for retries.
76
+
77
+ This prevents:
78
+ - **Stampedes**: 50 stale resources from a page load all get queued, not fetched simultaneously
79
+ - **Dedup waste**: Two requests for the same stale URL only produce one refetch
80
+ - **SW termination**: The queue promise is shared across all `event.waitUntil()` calls, keeping the SW alive
81
+ - **Transient failure loss**: a flaky network doesn't permanently lose the refresh — retries continue with backoff
82
+
83
+ ## Online recovery
84
+
85
+ When the browser fires `online`, the `client-injector` forwards the event to the SW. The SW runs `handleOnline()` in two phases:
86
+
87
+ **Phase 1 — staleVersions retry:** Any cache entries that failed to refetch after tag invalidation (while offline) are re-queued first via the batch refresh queue. This ensures invalidation-triggered refetches are prioritized.
88
+
89
+ **Phase 2 — reactive pattern scan:** The SW scans its reactive route patterns and for each cached URL matching a reactive pattern with `refetchOnReconnect: true`:
90
+
91
+ 1. Resolves the pattern config to get the reactive entry
92
+ 2. Calls `shouldReactiveRefresh(cachedResponse, config)` which checks if the entry is stale (past `staleTime`, or if `staleTime` is 0/undefined)
93
+ 3. If stale → queues a refresh via `queueRefresh(url)`
94
+
95
+ Non-reactive strategies (cache-first, network-first, stale-while-revalidate) do **not** participate in the online recovery scan — their contracts are stateless with respect to staleness. This naturally recovers from: background refresh failure while offline, tab closed while offline, and first request after connectivity returns.
96
+
97
+ ## 3-tier config resolution
98
+
99
+ The `strategy` field resolves through three priority levels:
100
+
101
+ 1. **Per-request (highest)** — passed as options to `fetchWithCache({ strategy })` or `useCachedFetch()`, or via `X-SW-Strategy` header
102
+ 2. **Route pattern** — configured in `features.serviceWorker.strategy.patterns` keyed by URL pattern
103
+ 3. **Global default (lowest)** — configured at `features.serviceWorker.strategy.default`
104
+
105
+ **Reactive-only fields** (`staleTime`, `refetchInterval`, `refetchOnReconnect`, `refetchOnFocus`) resolve through tiers:
106
+
107
+ | Field | Tier 1 (per-request) | Tier 2 (route pattern) | Tier 3 (global default) |
108
+ |-------|---------------------|----------------------|------------------------|
109
+ | `staleTime` | ✅ `X-SW-Stale-Time` header or `fetchWithCache({ staleTime })` | ✅ pattern entry | ✅ `reactive.defaults.staleTime` |
110
+ | `refetchInterval` | ❌ SW-initiated timer — not per-request | ✅ pattern entry | ✅ `reactive.defaults.refetchInterval` |
111
+ | `refetchOnReconnect` | ❌ SW-initiated — not per-request | ✅ pattern entry | ✅ `reactive.defaults.refetchOnReconnect` |
112
+ | `refetchOnFocus` | ❌ SW-initiated — not per-request | ✅ pattern entry | ✅ `reactive.defaults.refetchOnFocus` |
113
+
114
+ **Example resolution flow:**
115
+
116
+ ```
117
+ request to /api/todos with strategy "reactive"
118
+ → tier 1: X-SW-Stale-Time header? no
119
+ → tier 2: "/api/*" has staleTime: 30
120
+ → use 30s
121
+
122
+ request to /api/posts with strategy "reactive" (no route match)
123
+ → tier 1: X-SW-Stale-Time header? no
124
+ → tier 2: no route pattern
125
+ → tier 3: global staleTime: 0
126
+ → use 0s (always stale)
127
+ ```
128
+
129
+ **Why 3 tiers?**
130
+
131
+ - Per-request override handles edge cases (e.g., a critical endpoint needs immediate freshness)
132
+ - Route patterns cover the common case (e.g., `/api/*` is dynamic, `/static/*` is immutable)
133
+ - Global default avoids repeating the same config everywhere
134
+
135
+ ---
136
+
137
+ ## Body-hash GraphQL caching
138
+
139
+ GraphQL queries are POST requests with the query and variables in the body. Standard HTTP caching can't handle this because the URL is always the same but the response differs per query.
140
+
141
+ Swoff's approach:
142
+ 1. JSON-stringify `query` + `variables`
143
+ 2. SHA-256 hash via `crypto.subtle.digest()`
144
+ 3. First 16 hex chars become the cache key → `X-SW-Cache-Key: gql:<hash>`
145
+ 4. The SW caches under a virtual URL (`/__swc/gql:<hash>`)
146
+ 5. Different queries to the same endpoint don't collide
147
+
148
+ **Why not a normalized entity cache** (like Apollo/Relay)?
149
+
150
+ - Normalized caches require a schema-aware cache layer that normalizes every response into entities
151
+ - This adds significant complexity: schema introspection, entity merging, garbage collection
152
+ - Swoff is **config-driven and generates auditable code** — a normalized cache would need runtime logic that can't be generated statically
153
+ - The body-hash approach is simple, deterministic, and produces cache keys that are visible in the SW's Cache Storage
154
+
155
+ **Auto-tags from operation names:**
156
+
157
+ - `query getTodos { ... }` → tags: `["todos"]`
158
+ - `mutation createTodo(...)` → tags: `["todos", "todo"]`
159
+ - `mutation updateUserProfile(...)` → tags: `["user", "profile"]`
160
+ - These auto-invalidate after mutations, just like REST tag invalidation
161
+
162
+ ---
163
+
164
+ ## Dedup map + AbortController
165
+
166
+ In-flight GET requests to the same URL are deduplicated using an in-memory `Map<string, Promise<Response>>`:
167
+
168
+ 1. When `fetchWithCache(url)` is called, check if a request to `url` is already in-flight
169
+ 2. If yes, return a clone of the shared response promise
170
+ 3. If no, start a new fetch, store the promise in the map
171
+ 4. On completion (or failure), remove the entry from the map
172
+
173
+ **AbortController integration:**
174
+
175
+ - If an `AbortSignal` is provided, a one-time listener is registered on the signal
176
+ - On abort, the entry is removed from the dedup map so subsequent requests don't get a cancelled promise
177
+ - The listener is cleaned up on completion via `responsePromise.finally(cleanup)`
178
+ - Before reading from cache in offline mode, `signal.aborted` is checked and throws `AbortError`
179
+
180
+ **Why dedup?**
181
+ - Prevents multiple React components mounting simultaneously from firing duplicate network requests
182
+ - Reduces server load
183
+ - Each caller gets a clone — reading the body doesn't affect other consumers
184
+
185
+ ---
186
+
187
+ ## Mutation queue concurrency
188
+
189
+ The mutation queue stores writes in IndexedDB and replays them when online. Each mutation is an item with:
190
+
191
+ ```ts
192
+ {
193
+ id: string; // unique ID
194
+ method: string; // POST, PUT, PATCH, DELETE
195
+ url: string; // target URL
196
+ body?: unknown; // request body
197
+ headers?: Record<string, string>;
198
+ tags?: string[]; // invalidation tags
199
+ timestamp: number; // when it was queued
200
+ retryCount: number; // how many times it has been retried
201
+ status?: "pending" | "processing" | "failed";
202
+ }
203
+ ```
204
+
205
+ **IndexedDB structure:**
206
+ - Store: `MutationQueueItem`
207
+ - Index: `by-timestamp` (for FIFO replay order)
208
+
209
+ **Concurrency surface:**
210
+ - `getQueuePosition(id)` — returns the 0-based index of a mutation in the queue. Useful for showing "Your mutation is #3 in line"
211
+ - `getQueueItems()` — returns all pending items with their status and retry count
212
+ - `useMutationQueue()` — now exposes `{ pending, items, lastSync }` so the UI can render per-mutation progress
213
+ - `useMutationState(id)` — subscribes to a specific mutation's state changes via the mutation-state module
214
+
215
+ **Batch processing:**
216
+ - `batchSize` controls how many mutations fire per progress event
217
+ - `batchDelayMs` adds a delay between mutations (rate limiting)
218
+ - `maxRetries` limits retries before dropping (with exponential backoff: `retryBackoffMs × 2^retryCount`)
219
+ - `flushMutations()` provides a manual trigger (call after re-login to replay 401'd mutations)
220
+
221
+ ## Dual-replay coordination
222
+
223
+ Mutations queued while offline can be replayed by **both** the service worker (via Background Sync) and the client (when `online` fires). Swoff prevents double-execution with a simple rule:
224
+
225
+ 1. The SW checks for active clients via `self.clients.matchAll()`
226
+ 2. If any client page is open, the SW **skips processing entirely** — the client always wins when open
227
+ 3. If no client is open, the SW processes silently via Background Sync
228
+
229
+ This avoids the complexity of shared locks and timestamps:
230
+
231
+ - **Page is open**: client handles replay (preferred — has DOM access for progress events, can show real-time UI updates)
232
+ - **Page is closed**: SW handles replay via Background Sync (no data loss)
233
+ - **Both race**: only the client processes; the SW never interferes when clients exist
234
+
235
+ Mutations stored client-side go into the same IndexedDB store that the SW reads from (`swoff-queue`). When the SW stores a mutation offline (from `fetch-handler.ts`), it also writes to this store and notifies clients via `MUTATION_STORED` so they can attempt immediate replay.
236
+
237
+ ## Online-status awareness during mutation replay
238
+
239
+ Both the client and SW mutation processors check `navigator.onLine` per-mutation (not just once at start):
240
+
241
+ 1. Before each mutation replay attempt, the processor checks online status
242
+ 2. If offline, processing stops immediately and dispatches a `mutation-sync-complete` event with `interrupted: true`
243
+ 3. On next `online` event or Background Sync trigger, processing resumes from where it left off (FIFO order)
244
+ 4. This prevents partial network failures from corrupting the queue — if a fetch fails mid-batch due to connectivity loss, the remaining mutations stay queued
245
+
246
+ Why per-mutation instead of once?
247
+ - A user might go back offline mid-replay (e.g. walking through a tunnel)
248
+ - The background sync event might fire right as connectivity flickers
249
+ - Each mutation can independently fail and be retried; no single mutation's failure blocks others (beyond the interrupt signal)
250
+
251
+ ---
252
+
253
+ ## Server push transport: SSE vs WebSocket
254
+
255
+ Both transports are supported via `features.serverPush.type`.
256
+
257
+ | Aspect | SSE | WebSocket |
258
+ |--------|-----|-----------|
259
+ | Direction | Server → Client only | Bidirectional |
260
+ | Protocol | HTTP (standard) | WS (upgraded) |
261
+ | Browser support | All modern browsers | All modern browsers |
262
+ | Reconnection | Built-in `EventSource` auto-reconnects | Manual reconnection logic needed |
263
+ | Binary data | Text only | Text + binary |
264
+ | Server complexity | Simple — any HTTP server can send SSE | Requires WebSocket handshake + frame handling |
265
+ | Polyfill needed | No | No |
266
+
267
+ **Default: SSE**. It's simpler, the browser handles reconnection, and Swoff only needs server-to-client invalidation events — no bidirectional communication is required.
268
+
269
+ The SW manages the connection directly for reliability across page navigations. The client-side `server-push.ts` is a fallback that starts the connection when the SW is not yet active.
270
+
271
+ **Server event format (SSE):**
272
+ ```
273
+ event: invalidate
274
+ data: {"tags": ["todos", "categories"]}
275
+
276
+ ```
277
+
278
+ **Server message format (WebSocket):**
279
+ ```json
280
+ {"event": "invalidate", "tags": ["todos", "categories"]}
281
+ ```
282
+
283
+ When the SW receives an `invalidate` event, it calls `invalidateByTags(tags)` which:
284
+ 1. Removes matching cache entries from the runtime cache
285
+ 2. Tracks stale URLs in `staleVersions` (an in-memory `Map<cacheKey, timestamp>`)
286
+ 3. Queues each stale URL through the shared batch refresh queue (`queueRefresh`)
287
+ 4. On successful refetch, removes the entry from `staleVersions` and sends `CACHE_UPDATED` to all clients
288
+ 5. Any `staleVersions` that remain on next `online` event get priority retry in `handleOnline` phase 1
289
+
290
+ ---
291
+
292
+ ## Auth: memory-only tokens
293
+
294
+ Auth tokens are stored **in memory only** — never persisted to IndexedDB or localStorage. Only non-sensitive user data (`{ user, expiresAt }`) is stored in IndexedDB for offline user display.
295
+
296
+ **Security rationale:**
297
+ - A token in IndexedDB/localStorage persists on disk and can be extracted by any script running on the same origin
298
+ - A token in memory is cleared on page refresh and cannot be accessed by other tabs
299
+ - After page refresh, re-login (or token refresh via `refreshPath`) is required
300
+
301
+ **Auth flow:**
302
+ 1. User logs in → server returns token + user data
303
+ 2. `createAuthFromResponse(response)` extracts the token (edit this to match your backend)
304
+ 3. Token held in memory; user data optionally cached in IndexedDB
305
+ 4. `fetchWithCache(url, { auth: true })` calls `getAuth()` → `withAuthHeaders(headers, auth)`
306
+ 5. On 401: `clearAuth()` + dispatch `sw-auth-unauthorized` event
307
+
308
+ **Auth types:**
309
+ - `bearer`: `Authorization: Bearer <token>`
310
+ - `cookie`: sets `credentials: "include"`, no explicit header
311
+ - `custom`: you edit the generated `withAuthHeaders` function
312
+
313
+ ---
314
+
315
+ ## Cache strategy modes
316
+
317
+ `features.serviceWorker.strategy.mode` controls when the SW applies caching strategies to requests:
318
+
319
+ - **`"all"`** (default): every GET/HEAD request passes through the strategy dispatch system, including plain `fetch()` calls. This means third-party libraries that use `fetch()` are also cached.
320
+ - **`"explicit-only"`**: only requests with `X-SW-Cache-Strategy` header are processed by the strategy system. `fetchWithCache()` sets this header automatically. Plain `fetch()` calls pass through unmodified.
321
+
322
+ Use `"explicit-only"` when you want precise control over what gets cached and don't want the SW interfering with non-Swoff fetch calls.
323
+
324
+ **Request dispatch flow:**
325
+
326
+ ```
327
+ navigation (SPA fallback) → precache hit? → strategy dispatch → pass-through
328
+
329
+ cache-first:
330
+ → serve cache (if available)
331
+ → fall back to network on miss → storeRuntime
332
+
333
+ network-first:
334
+ → fetch → on failure → serve cache
335
+
336
+ stale-while-revalidate:
337
+ → serve cache (if available)
338
+ → always queueRefresh(url) (unconditional background refresh)
339
+
340
+ cache-only:
341
+ → serve cache only
342
+
343
+ network-only:
344
+ → fetch only (no cache interaction)
345
+
346
+ reactive:
347
+ → serve cache (if available)
348
+ → shouldReactiveRefresh? → stale? → queueRefresh(url)
349
+ → also: refetchInterval timer, refetchOnFocus, refetchOnReconnect → all gate through staleTime
350
+
351
+ On "online" event from window:
352
+ → handleOnline(phase 1) → staleVersions retry → queueRefresh()
353
+ → handleOnline(phase 2) → scan reactive patterns with refetchOnReconnect → queueRefresh()
354
+
355
+ On tag invalidation:
356
+ → delete cache entries
357
+ → mark staleVersions
358
+ → queueRefresh (through batch queue)
359
+ → on success: CACHE_UPDATED + cleanup staleVersions
360
+
361
+ ## Tag introspection
362
+
363
+ Swoff exposes three introspection functions for debugging and dynamic invalidation:
364
+
365
+ | Client function | SW handler | Description |
366
+ |---|---|---|
367
+ | `getUrlsForTag(tag)` → `{ url, actualUrl }[]` | `GET_URLS_FOR_TAG` | Query all URLs cached under a given tag |
368
+ | `getTagsForUrl(url)` → `string[]` | `GET_TAGS_FOR_URL` | Query all tags associated with a URL |
369
+ | `invalidateMatching(glob)` | `INVALIDATE_MATCHING` | Invalidate all cached entries whose URL matches a glob pattern |
370
+
371
+ The client functions use `MessageChannel` to communicate with the SW, receiving responses via `channel.port1.onmessage`. The SW handler queries the IndexedDB tag registry (opened via `openTagDB()`) and returns results synchronously through the channel port.
372
+
373
+ `invalidateMatching` scans all entries in the tag registry, filters by `matchGlob(url, globPattern)`, collects unique tags, and calls `invalidateByTag(tag)` for each matching tag. Invalidated entries are then queue-refreshed through the standard batch queue.
374
+ ```
package/docs/CLI.md ADDED
@@ -0,0 +1,148 @@
1
+ # CLI Reference
2
+
3
+ Full documentation for every `@swoff/cli` command and flag.
4
+
5
+ ## `init`
6
+
7
+ Creates `swoff.config.json` in your project root. Auto-detects:
8
+
9
+ - **Framework**: checks for react, vue, svelte dependencies in `package.json`; defaults to `"vanilla"`
10
+
11
+ ```
12
+ swoff init
13
+ swoff init --framework react # override auto-detection
14
+ ```
15
+
16
+ | Flag | Type | Description |
17
+ |------|------|-------------|
18
+ | `--framework` | `"react"` \| `"vue"` \| `"svelte"` \| `"vanilla"` | Override framework auto-detection |
19
+
20
+ ---
21
+
22
+ ## `generate`
23
+
24
+ Generates the service worker and all supporting files into `swoff/`.
25
+
26
+ ```
27
+ swoff generate
28
+ swoff generate --sw-only # regenerate only the service worker
29
+ swoff generate --files-only # regenerate only supporting files (not SW)
30
+ ```
31
+
32
+ | Flag | Description |
33
+ |------|-------------|
34
+ | `--sw-only` | Skip all client-side files; regenerate only `sw/template.js`, `sw/injector.ts`, `sw/generator.js` |
35
+ | `--files-only` | Skip SW regeneration; regenerate all client-side files (fetch-wrapper, hooks, etc.) |
36
+ | `--language` | `"ts"` \| `"js"` — override language auto-detection; forces `.tsx`/`.ts` or `.jsx`/`.js` output |
37
+
38
+ After generation, read `swoff/GUIDE.md` for documentation links.
39
+
40
+ ### What is generated
41
+
42
+ All files land in `swoff/`. See [API.md](./API.md) for the full reference.
43
+
44
+ | File | Condition | Purpose |
45
+ |------|-----------|---------|
46
+ | `client-injector.ts` | always | Single entry point — wires SW registration, PWA install, mutation queue, cross-tab sync |
47
+ | `fetch-wrapper.ts` | always | Unified fetch with caching, auth, offline queue, auto-invalidation |
48
+ | `mutation-queue.ts` | `mutationQueue.enabled` | Offline write queue in IndexedDB |
49
+ | `mutation-state.ts` | `mutationQueue.enabled` | Per-mutation status tracking |
50
+ | `server-push.ts` | `serverPush.enabled` | Client-side SSE/WebSocket connection manager |
51
+ | `cache.ts` | `tagInvalidation` | Low-level `invalidateByTag()` / `invalidateByTags()` |
52
+ | `invalidation-tags.ts` | `tagInvalidation` | Tag generation helpers from URL paths |
53
+ | `gql-wrapper.ts` | `graphql.enabled` | GraphQL wrapper with body-hash caching |
54
+ | `push.ts` | `pushNotifications.enabled` | Push notification subscription management |
55
+ | `background-sync.ts` | `backgroundSync` | Background Sync API registration |
56
+ | `auth/store.ts` | `auth.enabled` | Token/user persistence + auth header helpers |
57
+ | `auth/user.ts` | `auth.enabled` | User data caching |
58
+ | `auth/state.ts` | `auth.enabled` | Online/offline × auth state detection |
59
+ | `pwa/install.ts` | `pwa.enabled` | Install prompt handling |
60
+ | `hooks/*.tsx` | framework === "react" | React hooks (see [API.md](./API.md#react-hooks)) |
61
+ | `sw/template.js` | always | Service worker source — runs in SW scope |
62
+ | `sw/injector.ts` | always | SW registration logic |
63
+ | `sw/generator.js` | always | Build-time script — embeds asset hashes |
64
+ | `swoff.d.ts` | always | TypeScript declarations |
65
+ | `GUIDE.md` | always | Documentation links and quick-start info |
66
+ | `manifest.json` | `pwa.enabled` | Web app manifest |
67
+
68
+ ---
69
+
70
+ ## `add <feature>`
71
+
72
+ Enables a feature in `swoff.config.json` and immediately regenerates.
73
+
74
+ ```
75
+ swoff add pwa
76
+ swoff add mutation-queue
77
+ swoff add auth
78
+ swoff add tag-invalidation
79
+ swoff add cross-tab
80
+ swoff add background-sync
81
+ swoff add push-notification
82
+ swoff add gql-wrapper
83
+ swoff add server-push
84
+ ```
85
+
86
+ For object-type features (auth, mutation-queue, graphql, push-notifications, server-push),
87
+ this sets `enabled: true` with defaults. Tweak the config file afterwards for fine-grained options.
88
+
89
+ ---
90
+
91
+ ## `validate`
92
+
93
+ Validates `swoff.config.json` against the schema. Reports unknown fields, type mismatches,
94
+ and structural errors.
95
+
96
+ ```
97
+ swoff validate
98
+ ```
99
+
100
+ ---
101
+
102
+ ## `clean`
103
+
104
+ Removes generated Swoff files and config:
105
+
106
+ ```
107
+ swoff clean
108
+ ```
109
+
110
+ This deletes:
111
+ - `swoff/` directory (all generated files)
112
+ - `swoff.config.json`
113
+
114
+ ---
115
+
116
+ ## `help [command]`
117
+
118
+ Shows help text for a specific command.
119
+
120
+ ```
121
+ swoff help
122
+ swoff help init
123
+ swoff help generate
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Build script integration
129
+
130
+ The SW generator (`sw/generator.js`) must run after every build to produce the final
131
+ service worker file. Add it to your `package.json` build script:
132
+
133
+ ```json
134
+ "build": "vite build && node swoff/sw/generator.js"
135
+ ```
136
+
137
+ Or run it manually after each build:
138
+
139
+ ```bash
140
+ node swoff/sw/generator.js
141
+ ```
142
+
143
+ The generator:
144
+ 1. Reads `swoff.config.json` for output dir, version, and strategy config
145
+ 2. Reads `sw/template.js` — the service worker source code
146
+ 3. Collects all built assets from the output directory
147
+ 4. Replaces placeholders (`[[CACHE_NAME]]`, `[[ASSETS_LIST]]`, `[[AUTO_SKIP_WAITING]]`) with actual values
148
+ 5. Writes the final versioned SW file (e.g. `dist/sw-v1.2.3.js`) and `version.json`