coldwire-rails 0.1.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/VERSION +1 -0
- data/app/assets/javascripts/coldwire/archives.js +52 -0
- data/app/assets/javascripts/coldwire/cache_controller.js +1058 -0
- data/app/assets/javascripts/coldwire/entries.js +55 -0
- data/app/assets/javascripts/coldwire/format.js +60 -0
- data/app/assets/javascripts/coldwire/worker.js +35 -0
- data/app/controllers/coldwire/application_controller.rb +21 -0
- data/app/controllers/coldwire/caches_controller.rb +33 -0
- data/app/controllers/coldwire/service_worker_controller.rb +26 -0
- data/app/helpers/coldwire/service_worker_helper.rb +59 -0
- data/app/views/coldwire/caches/show.html.erb +304 -0
- data/app/views/coldwire/service_worker/offline_frame.html.erb +16 -0
- data/app/views/coldwire/service_worker/offline_page.html.erb +112 -0
- data/app/views/coldwire/service_worker/show.js.erb +66 -0
- data/config/importmap.rb +7 -0
- data/config/routes.rb +8 -0
- data/docs/README.md +19 -0
- data/docs/configuration.md +539 -0
- data/docs/how-it-works.md +64 -0
- data/docs/images/offline-fallback.png +0 -0
- data/docs/images/offline-settings-cached.png +0 -0
- data/docs/images/offline-settings.png +0 -0
- data/docs/setup.md +218 -0
- data/lib/coldwire/client/api.js +44 -0
- data/lib/coldwire/client/cookie.js +13 -0
- data/lib/coldwire/client/forced.js +17 -0
- data/lib/coldwire/client/identity.js +28 -0
- data/lib/coldwire/client/marker.js +35 -0
- data/lib/coldwire/client/register.js +19 -0
- data/lib/coldwire/client/store.js +69 -0
- data/lib/coldwire/client/sync.js +122 -0
- data/lib/coldwire/client_user_agent.rb +48 -0
- data/lib/coldwire/configuration.rb +319 -0
- data/lib/coldwire/debug.css +197 -0
- data/lib/coldwire/engine.rb +34 -0
- data/lib/coldwire/source.rb +50 -0
- data/lib/coldwire/version.rb +11 -0
- data/lib/coldwire/worker/archives.js +158 -0
- data/lib/coldwire/worker/events.js +79 -0
- data/lib/coldwire/worker/inspect.js +81 -0
- data/lib/coldwire/worker/ranges.js +136 -0
- data/lib/coldwire/worker/rules.js +100 -0
- data/lib/coldwire/worker/serve.js +226 -0
- data/lib/coldwire/worker/sync.js +234 -0
- data/lib/coldwire-rails.rb +5 -0
- data/lib/coldwire.rb +55 -0
- data/lib/generators/coldwire/install/install_generator.rb +110 -0
- data/lib/generators/coldwire/install/templates/coldwire.rb +53 -0
- data/lib/tasks/coldwire.rake +8 -0
- metadata +113 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
# Configuration
|
|
2
|
+
|
|
3
|
+
Everything lives in `config/initializers/coldwire.rb`. `bin/rails coldwire:install` writes
|
|
4
|
+
this file with every default. Only `auto_sync` really needs your attention; the rest has a
|
|
5
|
+
working default.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
Coldwire.configure do |config|
|
|
9
|
+
config.auto_sync do |sync|
|
|
10
|
+
sync.enabled = false
|
|
11
|
+
sync.precache_urls = -> { [] }
|
|
12
|
+
sync.interval = 6.hours
|
|
13
|
+
sync.max_age = 7.days
|
|
14
|
+
sync.concurrency = 4
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
config.cache_identity = -> { nil }
|
|
18
|
+
config.register_if = -> { true }
|
|
19
|
+
config.caching_enabled_by_default = true
|
|
20
|
+
config.offline_import = "@hotwired/turbo-rails"
|
|
21
|
+
|
|
22
|
+
config.cache_as_you_go = [ "/*" ]
|
|
23
|
+
config.never_cache = []
|
|
24
|
+
config.never_intercept = [ "/up" ]
|
|
25
|
+
|
|
26
|
+
config.cache_origins = []
|
|
27
|
+
config.cache_ranges = []
|
|
28
|
+
config.cache_archives = []
|
|
29
|
+
|
|
30
|
+
config.probe_path = "/up"
|
|
31
|
+
config.mark_cached_pages = true
|
|
32
|
+
config.ignore_query_params = true
|
|
33
|
+
config.cache_name = "coldwire"
|
|
34
|
+
config.worker_scope = "/"
|
|
35
|
+
end
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A bad path pattern or origin raises at boot, rather than as a 500 that quietly takes caching
|
|
39
|
+
down with it.
|
|
40
|
+
|
|
41
|
+
## Options at a glance
|
|
42
|
+
|
|
43
|
+
| Option | Default | What it does |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| [`auto_sync.enabled`](#autosyncenabled) | `false` | Keep the precache manifest current on an interval |
|
|
46
|
+
| [`auto_sync.precache_urls`](#autosyncprecache_urls) | `-> { [] }` | The pages to fetch, evaluated against your URL helpers |
|
|
47
|
+
| [`auto_sync.interval`](#autosyncinterval) | `6.hours` | How long to leave between syncs |
|
|
48
|
+
| [`auto_sync.max_age`](#autosyncmax_age) | `7.days` | Refetch a cached manifest page once it is older than this |
|
|
49
|
+
| [`auto_sync.concurrency`](#autosyncconcurrency) | `4` | Fetches in flight at once during a sync |
|
|
50
|
+
| [`cache_identity`](#cache_identity) | `-> { nil }` | Who the cache belongs to; changing it drops the cache |
|
|
51
|
+
| [`register_if`](#register_if) | `-> { true }` | Whether a page registers the worker at all |
|
|
52
|
+
| [`caching_enabled_by_default`](#caching_enabled_by_default) | `true` | Starting position of the Offline support switch. Not a master on/off |
|
|
53
|
+
| [`offline_import`](#offline_import) | `"@hotwired/turbo-rails"` | Importmap module the offline page loads to boot Turbo |
|
|
54
|
+
| [`cache_as_you_go`](#cache_as_you_go) | `["/*"]` | Pages stored as somebody browses. `/*` is everything |
|
|
55
|
+
| [`never_cache`](#never_cache) | `[]` | Never stored, by any route in. The one veto |
|
|
56
|
+
| [`never_intercept`](#never_intercept) | `["/up"]` | Paths the worker does not touch at all |
|
|
57
|
+
| [`cache_origins`](#cache_origins) | `[]` | Other origins the worker may cache |
|
|
58
|
+
| [`cache_ranges`](#cache_ranges) | `[]` | URLs whose `Range` requests are cached piece by piece |
|
|
59
|
+
| [`cache_archives`](#cache_archives) | `[]` | Large files somebody can choose to download |
|
|
60
|
+
| [`ignore_query_params`](#ignore_query_params) | `true` | Treat `/map` and `/map?zoom=9` as one page |
|
|
61
|
+
| [`probe_path`](#probe_path) | `"/up"` | Pinged to tell online from offline |
|
|
62
|
+
| [`mark_cached_pages`](#mark_cached_pages) | `true` | Stamp HTML served from cache |
|
|
63
|
+
| [`cache_name`](#cache_name) | `"coldwire"` | Cache API cache name; bump to invalidate everything |
|
|
64
|
+
| [`worker_scope`](#worker_scope) | `"/"` | Scope the worker registers at |
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## `auto_sync`
|
|
69
|
+
|
|
70
|
+
Grouped because these only mean anything together: a manifest with no interval is never
|
|
71
|
+
fetched, an interval with no manifest has nothing to fetch.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
config.auto_sync do |sync|
|
|
75
|
+
sync.enabled = true
|
|
76
|
+
sync.precache_urls = -> { Site.published.map { |site| site_path(site) } }
|
|
77
|
+
sync.interval = 6.hours
|
|
78
|
+
sync.max_age = 7.days
|
|
79
|
+
sync.concurrency = 4
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
There is no true background scheduling to use. WebKit ships neither Background Sync, Periodic
|
|
84
|
+
Background Sync, nor Background Fetch, so nothing can wake a worker in a Hotwire Native web
|
|
85
|
+
view. An open page works out when a sync is next owed and sleeps exactly that long; the work
|
|
86
|
+
then runs in the worker, independently of the page that started it.
|
|
87
|
+
|
|
88
|
+
A sync outlives the page that started it, but not necessarily the browser's patience. The
|
|
89
|
+
clock is restarted only when the worker reports a full pass. Resuming needs no bookmark —
|
|
90
|
+
each pass recomputes what is missing from what is actually in the cache.
|
|
91
|
+
|
|
92
|
+
Each pass:
|
|
93
|
+
|
|
94
|
+
| | |
|
|
95
|
+
|---|---|
|
|
96
|
+
| **Fetches what is missing** | a newly published record with no cached copy |
|
|
97
|
+
| **Refetches what is old** | a cached copy older than `max_age` |
|
|
98
|
+
| **Skips what is fine** | anything younger than `max_age` costs nothing |
|
|
99
|
+
| **Retires what left the manifest** | an unpublished record is dropped from the cache |
|
|
100
|
+
|
|
101
|
+
Retiring only touches entries the manifest owns. Assets, and pages cached by visiting them,
|
|
102
|
+
are never retired.
|
|
103
|
+
|
|
104
|
+
The offline settings page has a per-device switch that turns automatic syncing off, remembered in
|
|
105
|
+
`localStorage`. Switched off, no page holds a sync timer; **Sync now** still runs a pass.
|
|
106
|
+
|
|
107
|
+
<p align="center">
|
|
108
|
+
<img src="images/offline-settings.png" alt="Offline settings: status, force offline, auto sync, and downloads" width="280">
|
|
109
|
+
<img src="images/offline-settings-cached.png" alt="Offline settings: every cached entry, with search, sort, and delete" width="280">
|
|
110
|
+
</p>
|
|
111
|
+
|
|
112
|
+
### `auto_sync.enabled`
|
|
113
|
+
|
|
114
|
+
**Default:** `false`
|
|
115
|
+
|
|
116
|
+
Off unless asked for. Background fetching is a decision about somebody's data plan. Until
|
|
117
|
+
this is `true`, `precache_urls` is never fetched on its own — **Sync now** on the offline
|
|
118
|
+
settings page still runs a pass if you want one by hand.
|
|
119
|
+
|
|
120
|
+
### `auto_sync.precache_urls`
|
|
121
|
+
|
|
122
|
+
**Default:** `-> { [] }`
|
|
123
|
+
|
|
124
|
+
The pages to keep cached. Evaluated against your app's URL helpers, so `article_path` means
|
|
125
|
+
your route rather than one of Coldwire's. Give it an argument and it receives the controller:
|
|
126
|
+
|
|
127
|
+
```ruby
|
|
128
|
+
sync.precache_urls = -> { Article.published.map { |a| article_path(a) } }
|
|
129
|
+
|
|
130
|
+
sync.precache_urls = ->(controller) {
|
|
131
|
+
controller.current_user.articles.map { |a| article_path(a) }
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Listing a URL here is an explicit instruction: `cache_as_you_go` does not filter it.
|
|
136
|
+
`never_cache` still wins.
|
|
137
|
+
|
|
138
|
+
A stored page's stylesheets, scripts, and images are fetched with it, whatever the lists say.
|
|
139
|
+
|
|
140
|
+
### `auto_sync.interval`
|
|
141
|
+
|
|
142
|
+
**Default:** `6.hours`
|
|
143
|
+
|
|
144
|
+
How long to leave between syncs. An ActiveSupport duration works; the worker receives
|
|
145
|
+
seconds. Leave this long — a sync is a burst of fetches, not something to run on every
|
|
146
|
+
visit.
|
|
147
|
+
|
|
148
|
+
The interval is also written as a `<meta>` on every page, so a document that outlives a
|
|
149
|
+
config change follows the new value rather than the one it was born with.
|
|
150
|
+
|
|
151
|
+
### `auto_sync.max_age`
|
|
152
|
+
|
|
153
|
+
**Default:** `7.days`
|
|
154
|
+
|
|
155
|
+
Refetch a manifest page once its cached copy is older than this. `nil` fetches only what is
|
|
156
|
+
missing, so pages already cached are never noticed to have changed.
|
|
157
|
+
|
|
158
|
+
### `auto_sync.concurrency`
|
|
159
|
+
|
|
160
|
+
**Default:** `4`
|
|
161
|
+
|
|
162
|
+
Fetches in flight at once during a sync. Sequential would take a round trip per URL; all at
|
|
163
|
+
once would stall the app's own requests behind hundreds of connections.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## `cache_identity`
|
|
168
|
+
|
|
169
|
+
**Default:** `-> { nil }`
|
|
170
|
+
|
|
171
|
+
Who the cache belongs to, usually the signed-in user's id. Evaluated in the view, so
|
|
172
|
+
`current_user` is in scope. Recorded in `localStorage`; when it changes between page loads
|
|
173
|
+
the cache is dropped — which is what makes signing out, and switching accounts, safe.
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
config.cache_identity = -> { current_user&.id }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Leave it unset and the cache persists across sessions: fine for a single-user or fully
|
|
180
|
+
public app, wrong for anything else.
|
|
181
|
+
|
|
182
|
+
A few edges the setting already handles:
|
|
183
|
+
|
|
184
|
+
- No stored identity is not a change of identity — it is a browser that has not been told
|
|
185
|
+
yet. Treating empty `localStorage` as "somebody else" would destroy a good cache the first
|
|
186
|
+
time storage came back empty.
|
|
187
|
+
- The cache is not discarded while offline. There would be nothing to refill from. The
|
|
188
|
+
mismatch waits until there is a connection.
|
|
189
|
+
|
|
190
|
+
Cached pages contain whatever the session that fetched them could see. Setting this does not
|
|
191
|
+
encrypt them; it only drops them when the owner changes.
|
|
192
|
+
|
|
193
|
+
---
|
|
194
|
+
|
|
195
|
+
## `register_if`
|
|
196
|
+
|
|
197
|
+
**Default:** `-> { true }`
|
|
198
|
+
|
|
199
|
+
Whether a page registers the worker at all — and so whether it caches or syncs anything.
|
|
200
|
+
Evaluated in the view, so `request` and `current_user` are both in scope. A block that
|
|
201
|
+
declares a parameter is handed the request:
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
config.register_if = -> { true }
|
|
205
|
+
|
|
206
|
+
config.register_if = -> {
|
|
207
|
+
request.user_agent.to_s.include?("Hotwire Native") && current_user.present?
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
config.register_if = ->(request) { request.format.html? }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
A page that does not register does not cache or sync. The helper
|
|
214
|
+
`coldwire_service_worker_tag` already consults this, so you can leave the tag in the layout
|
|
215
|
+
and gate registration here.
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## `caching_enabled_by_default`
|
|
220
|
+
|
|
221
|
+
**Default:** `true`
|
|
222
|
+
|
|
223
|
+
Starting position of the Offline support switch on the offline settings page. It does not
|
|
224
|
+
turn offline support on or off for the app — people do that themselves, and their choice is
|
|
225
|
+
remembered on the device. A fresh device follows this.
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
config.caching_enabled_by_default = true
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
This is not [`register_if`](#register_if). `register_if` is the app's decision that the
|
|
232
|
+
worker should not run here at all. This is only where the switch starts.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## `offline_import`
|
|
237
|
+
|
|
238
|
+
**Default:** `"@hotwired/turbo-rails"`
|
|
239
|
+
|
|
240
|
+
The importmap module the offline page loads to boot Turbo. Hotwire Native reports "Turbo is
|
|
241
|
+
not present" for any page where `window.Turbo` never appears, so the fallback has to boot
|
|
242
|
+
Turbo — and Turbo alone, since one uncached module would fail the whole graph.
|
|
243
|
+
|
|
244
|
+
Set this to `nil` if you are not on importmap-rails, and load Turbo yourself in the offline
|
|
245
|
+
template instead. Override the template at
|
|
246
|
+
`app/views/coldwire/service_worker/offline_page.html.erb`.
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## `cache_as_you_go`
|
|
251
|
+
|
|
252
|
+
**Default:** `["/*"]` (everything you browse)
|
|
253
|
+
|
|
254
|
+
What browsing stores. These are the pages worth keeping as somebody moves through the app.
|
|
255
|
+
What a stored page needs in order to render — its stylesheets, its scripts, its images — is
|
|
256
|
+
stored with it, whether or not those match anything in the list.
|
|
257
|
+
|
|
258
|
+
```ruby
|
|
259
|
+
config.cache_as_you_go = [ "/sites", "/sites/:id", "/sites/:id/card" ]
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
`/*` is every path, including `/`. Narrow it to the pages worth keeping, or set `[]` to
|
|
263
|
+
store nothing by browsing. `never_cache` still wins either way.
|
|
264
|
+
|
|
265
|
+
It does not apply to the precache manifest: listing a URL in `precache_urls` is an explicit
|
|
266
|
+
instruction, and quietly declining it would mean precaching 84 pages and silently getting 60.
|
|
267
|
+
|
|
268
|
+
See [Pattern syntax](#pattern-syntax) for how strings and Regexps match.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## `never_cache`
|
|
273
|
+
|
|
274
|
+
**Default:** `[]`
|
|
275
|
+
|
|
276
|
+
Never stored, by any route in: not by browsing, not as a subresource of a page that
|
|
277
|
+
references it, not by the precache manifest. The one veto.
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
config.never_cache = [ "/users/:id/edit", %r{^/admin(/|$)} ]
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
**`never_cache` always wins**, the manifest included. Between two explicit instructions that
|
|
284
|
+
contradict each other, the one that says do not store is the safe one to honour — it is
|
|
285
|
+
where auth pages and admin go.
|
|
286
|
+
|
|
287
|
+
This is not [`never_intercept`](#never_intercept). `never_cache` means *intercept but never
|
|
288
|
+
store automatically*, so the request still reaches your offline view. Put auth paths here.
|
|
289
|
+
|
|
290
|
+
See [Pattern syntax](#pattern-syntax).
|
|
291
|
+
|
|
292
|
+
---
|
|
293
|
+
|
|
294
|
+
## `never_intercept`
|
|
295
|
+
|
|
296
|
+
**Default:** `["/up"]` (`probe_path` is added for you)
|
|
297
|
+
|
|
298
|
+
Paths the worker does not touch at all, matched as prefixes. Coldwire's own worker script
|
|
299
|
+
and manifest are added for you. The request goes straight to the network and so fails
|
|
300
|
+
outright offline, showing the SDK's error screen rather than your offline page — which is
|
|
301
|
+
what you want for a health check, and almost never what you want for a page.
|
|
302
|
+
|
|
303
|
+
```ruby
|
|
304
|
+
config.never_intercept = [ "/up", "/health" ]
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Unlike `cache_as_you_go` and `never_cache`, these are **prefix strings**, not route
|
|
308
|
+
patterns. `/up` matches `/up` and `/up/ready`. Regexps are not accepted here.
|
|
309
|
+
|
|
310
|
+
Compare:
|
|
311
|
+
|
|
312
|
+
| Setting | Worker | Offline |
|
|
313
|
+
|---|---|---|
|
|
314
|
+
| `never_intercept` | stands aside | the request goes to a dead network |
|
|
315
|
+
| `never_cache` | still answers | your offline page can still render |
|
|
316
|
+
|
|
317
|
+
So auth pages, admin, anything sensitive: `never_cache`. A probe the worker must never be
|
|
318
|
+
able to answer from a cache: `never_intercept`.
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## Pattern syntax
|
|
323
|
+
|
|
324
|
+
`cache_as_you_go`, `never_cache`, and `cache_ranges` take the same shapes: route-pattern
|
|
325
|
+
strings, or Regexps.
|
|
326
|
+
|
|
327
|
+
A **string** is a route pattern, and matches that shape and nothing else:
|
|
328
|
+
|
|
329
|
+
| Pattern | Matches | Does not match |
|
|
330
|
+
|---|---|---|
|
|
331
|
+
| `/sites` | `/sites` | `/sites/1`, `/sites/search` |
|
|
332
|
+
| `/sites/:id` | `/sites/1` | `/sites`, `/sites/1/card` |
|
|
333
|
+
| `/sites/:id/card` | `/sites/1/card` | `/sites/1/notices` |
|
|
334
|
+
| `/sites/*` | `/sites/1`, `/sites/1/card` | `/sites` |
|
|
335
|
+
| `/*` | `/`, `/sites`, `/sites/1/card` | — |
|
|
336
|
+
|
|
337
|
+
`:name` is exactly one segment; `*` takes everything remaining and may only be last. A
|
|
338
|
+
lone `/*` is the exception: it is every path, including `/`. `/sites/*` still does not
|
|
339
|
+
match `/sites`.
|
|
340
|
+
|
|
341
|
+
Coldwire raises at boot on anything else — a missing leading slash, `*` in the middle, a
|
|
342
|
+
malformed segment — because every mistake of this shape fails the same silent way: the rule
|
|
343
|
+
never matches, and you find out when a page you expected offline is not there.
|
|
344
|
+
|
|
345
|
+
Prefer the explicit shapes over `*`. A prefix reads as "this section of the app" but takes
|
|
346
|
+
everything underneath with it, and with `ignore_query_params` on a single `/sites/search`
|
|
347
|
+
entry ends up answering every search.
|
|
348
|
+
|
|
349
|
+
A trailing slash is trimmed, so `/sites/` and `/sites` behave alike. `/` itself is left
|
|
350
|
+
alone — chomping that would leave an empty string and the rule would vanish.
|
|
351
|
+
|
|
352
|
+
A **Regexp** is tested against the path by JavaScript's `RegExp`, so write JS syntax — `^`
|
|
353
|
+
and `$`, not `\A` and `\z`. The `i` flag is honoured; Coldwire raises on `\A`/`\z`/`\Z` and
|
|
354
|
+
the `x`/`m` flags rather than letting a rule silently never match.
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
config.never_cache = [ %r{^/admin(/|$)}, %r{^/users/[^/]+/edit$} ]
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## `cache_origins`
|
|
363
|
+
|
|
364
|
+
**Default:** `[]`
|
|
365
|
+
|
|
366
|
+
Origins besides your own that the worker may cache. Each has to send CORS headers naming
|
|
367
|
+
your app, or the response arrives opaque — status 0, no headers, no readable body — and
|
|
368
|
+
there is nothing worth storing. Ranged sources must also expose `Content-Range`.
|
|
369
|
+
|
|
370
|
+
```ruby
|
|
371
|
+
config.cache_origins = [ "https://tiles.example.com" ]
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Bare origins only: a scheme and a host, no path, no trailing slash. Anything else raises at
|
|
375
|
+
boot, because a malformed origin silently fails to match a request's origin.
|
|
376
|
+
|
|
377
|
+
Cross-origin requests are passed through unless the origin is listed here.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## `cache_ranges`
|
|
382
|
+
|
|
383
|
+
**Default:** `[]`
|
|
384
|
+
|
|
385
|
+
URLs whose `Range` requests are cached piece by piece, keyed by the range. The Cache API
|
|
386
|
+
refuses a `206`, so without this, tiles and media are uncacheable. Same [pattern
|
|
387
|
+
syntax](#pattern-syntax) as the lists above.
|
|
388
|
+
|
|
389
|
+
```ruby
|
|
390
|
+
config.cache_ranges = [ "/tiles/*", %r{\.pmtiles$} ]
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
Patterns match the URL path, same as the other lists — not the full URL. A cross-origin
|
|
394
|
+
tile at `https://tiles.example.com/basemap.pmtiles` is allowed only when that origin is in
|
|
395
|
+
[`cache_origins`](#cache_origins) *and* its path matches a rule here.
|
|
396
|
+
|
|
397
|
+
This pairs with [`cache_archives`](#cache_archives): `cache_ranges` caches the slices
|
|
398
|
+
actually read, so the places you have already opened work offline, and downloading the
|
|
399
|
+
archive is how the rest does.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## `cache_archives`
|
|
404
|
+
|
|
405
|
+
**Default:** `[]`
|
|
406
|
+
|
|
407
|
+
Large files somebody can choose to keep — a tile archive, an audio guide, a reference PDF.
|
|
408
|
+
Nothing downloads on its own: hundreds of megabytes over somebody's connection is their
|
|
409
|
+
decision. The offline settings page shows **Download**, then **Download again** and **Delete** once it
|
|
410
|
+
is on the device, or **Resume** where a download stopped part way.
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
config.cache_archives = [
|
|
414
|
+
{ url: "https://tiles.example.com/basemap.pmtiles",
|
|
415
|
+
title: "Offline map",
|
|
416
|
+
description: "The whole coast, rather than only the places you have opened." }
|
|
417
|
+
]
|
|
418
|
+
```
|
|
419
|
+
|
|
420
|
+
A bare URL string works too, and the filename becomes the title. Each URL must be absolute.
|
|
421
|
+
|
|
422
|
+
Files arrive in 8 MB chunks, which is what makes a dropped connection cost seconds instead
|
|
423
|
+
of the whole download. A `Range` request against a downloaded archive is answered by
|
|
424
|
+
slicing the chunks.
|
|
425
|
+
|
|
426
|
+
If the file lives on another origin, list that origin in [`cache_origins`](#cache_origins).
|
|
427
|
+
|
|
428
|
+
---
|
|
429
|
+
|
|
430
|
+
## `ignore_query_params`
|
|
431
|
+
|
|
432
|
+
**Default:** `true`
|
|
433
|
+
|
|
434
|
+
Treat `/map` and `/map?lat=44.1&zoom=9` as one cached page. The query is dropped both when
|
|
435
|
+
matching and in the key an entry is stored under. Matching alone would still let a map that
|
|
436
|
+
rewrites `lat`/`lng`/`zoom` on every pan write hundreds of near-duplicate entries.
|
|
437
|
+
|
|
438
|
+
This is blunt, deliberately. It also collapses query strings that genuinely select content:
|
|
439
|
+
`/search?q=otters` and `/search?q=puffins` become one entry. Set it to `false` if your app
|
|
440
|
+
caches pages whose content depends on the query.
|
|
441
|
+
|
|
442
|
+
```ruby
|
|
443
|
+
config.ignore_query_params = false
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## `probe_path`
|
|
449
|
+
|
|
450
|
+
**Default:** `"/up"`
|
|
451
|
+
|
|
452
|
+
What the offline settings page pings to tell online from offline, because `navigator.onLine` only
|
|
453
|
+
reports whether an interface is up. Added to `never_intercept` for you — a probe answered
|
|
454
|
+
from the cache would resolve with the network down, which is precisely backwards.
|
|
455
|
+
|
|
456
|
+
```ruby
|
|
457
|
+
config.probe_path = "/up"
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Point it at whatever health check your app already has. The path must not require
|
|
461
|
+
authentication the worker cannot satisfy.
|
|
462
|
+
|
|
463
|
+
---
|
|
464
|
+
|
|
465
|
+
## `mark_cached_pages`
|
|
466
|
+
|
|
467
|
+
**Default:** `true`
|
|
468
|
+
|
|
469
|
+
Stamp HTML the worker serves from cache *because the network was unavailable* before it
|
|
470
|
+
reaches the page:
|
|
471
|
+
|
|
472
|
+
```html
|
|
473
|
+
<html data-coldwire-offline data-coldwire-cached-at="1756400000">
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
Two markers, because they are read at different moments: the `<html>` attributes are there
|
|
477
|
+
for the first paint of a cold boot, before any JS runs, and a `<meta name="coldwire-offline">`
|
|
478
|
+
for Turbo visits, since Turbo merges the head but never copies `<html>` attributes.
|
|
479
|
+
`coldwire_service_worker_tag` mirrors the meta onto `<html>` on each `turbo:load`.
|
|
480
|
+
|
|
481
|
+
Any CSS can key off the attribute. With Tailwind v4, two custom variants give you
|
|
482
|
+
`offline:` and `online:`:
|
|
483
|
+
|
|
484
|
+
```css
|
|
485
|
+
@custom-variant offline (html[data-coldwire-offline] &);
|
|
486
|
+
@custom-variant online (html:not([data-coldwire-offline]) &);
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
From JavaScript, `window.Coldwire`:
|
|
490
|
+
|
|
491
|
+
```js
|
|
492
|
+
Coldwire.isOffline() // this page did not come from the network
|
|
493
|
+
Coldwire.isForcedOffline() // …because the switch is on, rather than for want of a signal
|
|
494
|
+
Coldwire.isCachingEnabled() // the Offline support switch, not navigator.serviceWorker
|
|
495
|
+
Coldwire.cachedAt() // a Date, or null if it came from the network
|
|
496
|
+
Coldwire.onChange((state) => { … }) // fires on every Turbo visit and on toggling force
|
|
497
|
+
// offline; returns its own unsubscribe
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
`isOffline()` reads the marker rather than `navigator.onLine`, which a web view reports
|
|
501
|
+
unreliably in both directions. `onChange` is what lets a map put its remote sources back
|
|
502
|
+
without a reload.
|
|
503
|
+
|
|
504
|
+
Set this to `false` if you do not want the stamp. The worker still serves from cache; the
|
|
505
|
+
page just cannot tell.
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## `cache_name`
|
|
510
|
+
|
|
511
|
+
**Default:** `"coldwire"`
|
|
512
|
+
|
|
513
|
+
Name of the Cache API cache. Bump it to invalidate every entry at once — after a deploy
|
|
514
|
+
that changes HTML structure enough that old copies would mis-render, for example.
|
|
515
|
+
|
|
516
|
+
```ruby
|
|
517
|
+
config.cache_name = "coldwire-v2"
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
This is a blunt instrument. `auto_sync.max_age` is how individual manifest pages go stale;
|
|
521
|
+
`cache_identity` is how a user change drops the cache. Bumping the name drops everything
|
|
522
|
+
for everyone, assets included.
|
|
523
|
+
|
|
524
|
+
---
|
|
525
|
+
|
|
526
|
+
## `worker_scope`
|
|
527
|
+
|
|
528
|
+
**Default:** `"/"`
|
|
529
|
+
|
|
530
|
+
Scope the worker claims. The worker is served from the engine mount point, so the response
|
|
531
|
+
also sends `Service-Worker-Allowed` to widen it past that directory — otherwise a mount at
|
|
532
|
+
`/offline` would only control `/offline/*`.
|
|
533
|
+
|
|
534
|
+
```ruby
|
|
535
|
+
config.worker_scope = "/"
|
|
536
|
+
```
|
|
537
|
+
|
|
538
|
+
Leave this at `/` unless you have a reason to let the worker see only part of the origin.
|
|
539
|
+
A narrower scope means pages outside it are neither cached nor served offline.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# How it works
|
|
2
|
+
|
|
3
|
+
Six things break a naive offline cache in a Hotwire app. Four bite you in any browser; two
|
|
4
|
+
are Hotwire Native holding you to a stricter standard. Coldwire handles all six, which is
|
|
5
|
+
what lets one cache serve a plain Hotwire app, a PWA, and Hotwire Native.
|
|
6
|
+
|
|
7
|
+
When a visit has no cached copy and no network, people see this — a `200` that boots
|
|
8
|
+
Turbo — rather than a native error screen:
|
|
9
|
+
|
|
10
|
+
<p align="center">
|
|
11
|
+
<img src="images/offline-fallback.png" alt="The offline fallback: You're offline. This page isn't available offline. Reconnect and try again." width="280">
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
1. **`Vary: Accept` silently defeats precaching.** Rails answers HTML with `Vary: Accept`
|
|
15
|
+
and `cache.match()` honors it. Precaching fetches with `Accept: */*`; Turbo asks for
|
|
16
|
+
`text/html`. So a precached page only ever matches *another precache*, never a real
|
|
17
|
+
visit — and it looks like it works, because caching pages as you visit them still does.
|
|
18
|
+
Coldwire matches with `{ ignoreVary: true }`.
|
|
19
|
+
2. **A non-2xx offline page is never shown.** *(Native.)* Its adapter posts
|
|
20
|
+
`visitRequestFailed` with a location, an identifier and a status — the response body
|
|
21
|
+
never crosses into Swift, so no iOS override can render a `503`. Coldwire's fallback is
|
|
22
|
+
a `200`.
|
|
23
|
+
3. **Turbo Frames need a frame.** A frame request discards any response without a matching
|
|
24
|
+
`<turbo-frame>`, leaving the frame loading forever. Coldwire reads the `Turbo-Frame`
|
|
25
|
+
header and answers with one.
|
|
26
|
+
4. **A followed redirect poisons the cache.** A signed-out request to `/` gets a `302` that
|
|
27
|
+
`fetch` follows; the result looks fine and `cache.put()` stores it without complaint.
|
|
28
|
+
Now `/` holds the sign-in page and keeps `redirected: true` — and serving a redirected
|
|
29
|
+
response for a navigation is a network error by spec, so the app fails to cold launch
|
|
30
|
+
offline. Coldwire refuses to store one.
|
|
31
|
+
5. **The offline page itself must boot Turbo.** *(Native.)* Its adapter waits for
|
|
32
|
+
`window.Turbo` and reports *"The page could not be loaded because Turbo is not present"*
|
|
33
|
+
if it never appears. Plain-HTML offline pages are not renderable in the app at all.
|
|
34
|
+
6. **Assets must not receive HTML.** A stylesheet handed an HTML offline page is just a
|
|
35
|
+
broken asset. Coldwire serves the fallback only to requests that want HTML, and
|
|
36
|
+
everything else an empty `504`.
|
|
37
|
+
|
|
38
|
+
The Cache API also ignores HTTP freshness headers entirely, and WebKit drops `Date` from
|
|
39
|
+
`match()`. So Coldwire stamps unix seconds onto the *request key* it stores under —
|
|
40
|
+
`keys()` hands it back, and URL matching still finds the entry. That is what the cached
|
|
41
|
+
list's "2 hours ago" reads.
|
|
42
|
+
|
|
43
|
+
While the network answers, every request goes to it. Coldwire's job starts when the
|
|
44
|
+
network stops: then the stored copy answers, or the offline page does.
|
|
45
|
+
|
|
46
|
+
## What the cache looks like to your server
|
|
47
|
+
|
|
48
|
+
A request carries the user agent of whoever makes it, and a service worker is not the
|
|
49
|
+
page. On Android that is the difference between the app and a browser: Hotwire Native sets
|
|
50
|
+
the agent on the web view, and `android.webkit.ServiceWorkerWebSettings` has no such
|
|
51
|
+
setting, so nothing the app configures reaches a worker's fetches. They cannot set one
|
|
52
|
+
either: Chromium drops a `User-Agent` given to `fetch`. WebKit has no such split, which is
|
|
53
|
+
why only Android is affected.
|
|
54
|
+
|
|
55
|
+
Left alone, an Android app's cache fills with pages rendered for a browser: navigation
|
|
56
|
+
chrome the app hides, and none of the markup that depends on knowing it is the app.
|
|
57
|
+
|
|
58
|
+
A cookie is the one thing the browser attaches by itself to every same-origin request,
|
|
59
|
+
whoever makes it. So each page writes its own user agent into `coldwire-user-agent`, and a
|
|
60
|
+
middleware reads it back before anything else in the stack runs. `hotwire_native_app?`,
|
|
61
|
+
your layout and `register_if` then see the client the request actually came from.
|
|
62
|
+
|
|
63
|
+
Nothing to configure. The user agent was always the client's to state, and a cookie is as
|
|
64
|
+
much the client's as the header is.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|