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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +53 -0
  3. data/LICENSE +21 -0
  4. data/README.md +69 -0
  5. data/VERSION +1 -0
  6. data/app/assets/javascripts/coldwire/archives.js +52 -0
  7. data/app/assets/javascripts/coldwire/cache_controller.js +1058 -0
  8. data/app/assets/javascripts/coldwire/entries.js +55 -0
  9. data/app/assets/javascripts/coldwire/format.js +60 -0
  10. data/app/assets/javascripts/coldwire/worker.js +35 -0
  11. data/app/controllers/coldwire/application_controller.rb +21 -0
  12. data/app/controllers/coldwire/caches_controller.rb +33 -0
  13. data/app/controllers/coldwire/service_worker_controller.rb +26 -0
  14. data/app/helpers/coldwire/service_worker_helper.rb +59 -0
  15. data/app/views/coldwire/caches/show.html.erb +304 -0
  16. data/app/views/coldwire/service_worker/offline_frame.html.erb +16 -0
  17. data/app/views/coldwire/service_worker/offline_page.html.erb +112 -0
  18. data/app/views/coldwire/service_worker/show.js.erb +66 -0
  19. data/config/importmap.rb +7 -0
  20. data/config/routes.rb +8 -0
  21. data/docs/README.md +19 -0
  22. data/docs/configuration.md +539 -0
  23. data/docs/how-it-works.md +64 -0
  24. data/docs/images/offline-fallback.png +0 -0
  25. data/docs/images/offline-settings-cached.png +0 -0
  26. data/docs/images/offline-settings.png +0 -0
  27. data/docs/setup.md +218 -0
  28. data/lib/coldwire/client/api.js +44 -0
  29. data/lib/coldwire/client/cookie.js +13 -0
  30. data/lib/coldwire/client/forced.js +17 -0
  31. data/lib/coldwire/client/identity.js +28 -0
  32. data/lib/coldwire/client/marker.js +35 -0
  33. data/lib/coldwire/client/register.js +19 -0
  34. data/lib/coldwire/client/store.js +69 -0
  35. data/lib/coldwire/client/sync.js +122 -0
  36. data/lib/coldwire/client_user_agent.rb +48 -0
  37. data/lib/coldwire/configuration.rb +319 -0
  38. data/lib/coldwire/debug.css +197 -0
  39. data/lib/coldwire/engine.rb +34 -0
  40. data/lib/coldwire/source.rb +50 -0
  41. data/lib/coldwire/version.rb +11 -0
  42. data/lib/coldwire/worker/archives.js +158 -0
  43. data/lib/coldwire/worker/events.js +79 -0
  44. data/lib/coldwire/worker/inspect.js +81 -0
  45. data/lib/coldwire/worker/ranges.js +136 -0
  46. data/lib/coldwire/worker/rules.js +100 -0
  47. data/lib/coldwire/worker/serve.js +226 -0
  48. data/lib/coldwire/worker/sync.js +234 -0
  49. data/lib/coldwire-rails.rb +5 -0
  50. data/lib/coldwire.rb +55 -0
  51. data/lib/generators/coldwire/install/install_generator.rb +110 -0
  52. data/lib/generators/coldwire/install/templates/coldwire.rb +53 -0
  53. data/lib/tasks/coldwire.rake +8 -0
  54. 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