@swoff/cli 0.3.8 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -33
- package/dist/__tests__/assemble-sw.test.js +8 -38
- package/dist/__tests__/assemble-sw.test.js.map +1 -1
- package/dist/__tests__/cli-integration.test.js +119 -11
- package/dist/__tests__/cli-integration.test.js.map +1 -1
- package/dist/__tests__/config-types.test.js +32 -15
- package/dist/__tests__/config-types.test.js.map +1 -1
- package/dist/__tests__/file-generators.test.js +2 -2
- package/dist/__tests__/file-generators.test.js.map +1 -1
- package/dist/__tests__/glob-matcher.test.js +86 -0
- package/dist/__tests__/glob-matcher.test.js.map +1 -0
- package/dist/__tests__/invalidation-tags.test.js +207 -23
- package/dist/__tests__/invalidation-tags.test.js.map +1 -1
- package/dist/__tests__/loader.test.js +12 -12
- package/dist/__tests__/loader.test.js.map +1 -1
- package/dist/__tests__/validator.test.js +144 -97
- package/dist/__tests__/validator.test.js.map +1 -1
- package/dist/index.js +11 -10
- package/dist/index.js.map +1 -1
- package/dist/lib/cli/help.js +0 -5
- package/dist/lib/cli/help.js.map +1 -1
- package/dist/lib/commands/add.js +50 -32
- package/dist/lib/commands/add.js.map +1 -1
- package/dist/lib/commands/clean.js +1 -15
- package/dist/lib/commands/clean.js.map +1 -1
- package/dist/lib/commands/generate-guide.js +2 -2
- package/dist/lib/commands/generate-guide.js.map +1 -1
- package/dist/lib/commands/generate.js +7 -21
- package/dist/lib/commands/generate.js.map +1 -1
- package/dist/lib/commands/info.js +8 -6
- package/dist/lib/commands/info.js.map +1 -1
- package/dist/lib/commands/init.js +2 -10
- package/dist/lib/commands/init.js.map +1 -1
- package/dist/lib/commands/validate.js +3 -3
- package/dist/lib/commands/validate.js.map +1 -1
- package/dist/lib/config/loader.js +3 -6
- package/dist/lib/config/loader.js.map +1 -1
- package/dist/lib/config/validator.js +157 -88
- package/dist/lib/config/validator.js.map +1 -1
- package/dist/lib/generators/file-generators/cache.js +2 -10
- package/dist/lib/generators/file-generators/cache.js.map +1 -1
- package/dist/lib/generators/file-generators/client-injector.js +50 -11
- package/dist/lib/generators/file-generators/client-injector.js.map +1 -1
- package/dist/lib/generators/file-generators/fetch-wrapper.js +92 -64
- package/dist/lib/generators/file-generators/fetch-wrapper.js.map +1 -1
- package/dist/lib/generators/file-generators/generate-hooks.js +1 -1
- package/dist/lib/generators/file-generators/generate-hooks.js.map +1 -1
- package/dist/lib/generators/file-generators/gql-wrapper.js +1 -14
- package/dist/lib/generators/file-generators/gql-wrapper.js.map +1 -1
- package/dist/lib/generators/file-generators/guide-generator.js +6 -640
- package/dist/lib/generators/file-generators/guide-generator.js.map +1 -1
- package/dist/lib/generators/file-generators/invalidation-tags.js +206 -19
- package/dist/lib/generators/file-generators/invalidation-tags.js.map +1 -1
- package/dist/lib/generators/file-generators/manifest.js +2 -2
- package/dist/lib/generators/file-generators/manifest.js.map +1 -1
- package/dist/lib/generators/file-generators/mutation-queue.js +29 -9
- package/dist/lib/generators/file-generators/mutation-queue.js.map +1 -1
- package/dist/lib/generators/file-generators/push.js +6 -7
- package/dist/lib/generators/file-generators/push.js.map +1 -1
- package/dist/lib/generators/file-generators/quick-readme.js +8 -125
- package/dist/lib/generators/file-generators/quick-readme.js.map +1 -1
- package/dist/lib/generators/file-generators/server-push.js +11 -1
- package/dist/lib/generators/file-generators/server-push.js.map +1 -1
- package/dist/lib/generators/file-generators/sw-generator-build.js +8 -9
- package/dist/lib/generators/file-generators/sw-generator-build.js.map +1 -1
- package/dist/lib/generators/file-generators/sw-injector.js +2 -1
- package/dist/lib/generators/file-generators/sw-injector.js.map +1 -1
- package/dist/lib/generators/file-generators/type-definitions.js +0 -3
- package/dist/lib/generators/file-generators/type-definitions.js.map +1 -1
- package/dist/lib/generators/sw-generator.js +8 -18
- package/dist/lib/generators/sw-generator.js.map +1 -1
- package/dist/lib/generators/sw-sections/activate-handler.js.map +1 -1
- package/dist/lib/generators/sw-sections/assemble-sw.js +14 -11
- package/dist/lib/generators/sw-sections/assemble-sw.js.map +1 -1
- package/dist/lib/generators/sw-sections/background-sync-handler.js +59 -17
- package/dist/lib/generators/sw-sections/background-sync-handler.js.map +1 -1
- package/dist/lib/generators/sw-sections/config-header.js +2 -2
- package/dist/lib/generators/sw-sections/config-header.js.map +1 -1
- package/dist/lib/generators/sw-sections/fetch-handler.js +437 -136
- package/dist/lib/generators/sw-sections/fetch-handler.js.map +1 -1
- package/dist/lib/generators/sw-sections/message-handler.js +55 -15
- package/dist/lib/generators/sw-sections/message-handler.js.map +1 -1
- package/dist/lib/generators/sw-sections/sw-push.js +67 -0
- package/dist/lib/generators/sw-sections/sw-push.js.map +1 -0
- package/dist/lib/generators/sw-sections/tag-management.js +60 -20
- package/dist/lib/generators/sw-sections/tag-management.js.map +1 -1
- package/dist/lib/generators/swoff-files-generator.js +5 -15
- package/dist/lib/generators/swoff-files-generator.js.map +1 -1
- package/dist/lib/shared/config-types.js +121 -71
- package/dist/lib/shared/config-types.js.map +1 -1
- package/dist/lib/shared/glob-matcher.js +77 -0
- package/dist/lib/shared/glob-matcher.js.map +1 -0
- package/dist/lib/utils/tty-status.js +21 -0
- package/dist/lib/utils/tty-status.js.map +1 -0
- package/docs/API.md +565 -0
- package/docs/ARCHITECTURE.md +374 -0
- package/docs/CLI.md +148 -0
- package/docs/COMPARISON.md +192 -0
- package/docs/CONFIG.md +277 -0
- package/docs/ECOSYSTEM.md +33 -0
- package/package.json +2 -1
- package/templates/hooks/useAuth.jsx +2 -2
- package/templates/hooks/useAuth.tsx +2 -2
- package/templates/hooks/useCachedFetch.jsx +3 -28
- package/templates/hooks/useCachedFetch.tsx +2 -33
- package/templates/hooks/useMutation.tsx +13 -1
- package/templates/hooks/useMutationQueue.jsx +2 -1
- package/templates/hooks/useMutationQueue.tsx +2 -1
- package/templates/hooks/useMutationState.jsx +8 -22
- package/templates/hooks/useMutationState.tsx +8 -24
- package/templates/hooks/usePushSubscription.jsx +3 -3
- 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`
|