coldwire-rails 0.1.0 → 0.2.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.
@@ -142,6 +142,96 @@ module Coldwire
142
142
  @auto_sync
143
143
  end
144
144
 
145
+ # Clearing out what has gone unused, so a cache that fills as people browse does not fill
146
+ # forever. Only ever removes; never fetches.
147
+ #
148
+ # config.garbage_collection do |gc|
149
+ # gc.max_age = 60 * 60 * 24 * 60
150
+ # gc.max_size = 500 * 1024 * 1024
151
+ # end
152
+ def garbage_collection
153
+ @garbage_collection ||= GarbageCollection.new
154
+ yield(@garbage_collection) if block_given?
155
+
156
+ @garbage_collection
157
+ end
158
+
159
+ # Deleting is the one cache operation with no way back: whatever goes is gone until the
160
+ # network can be reached again. So a sweep happens only with a connection confirmed, and
161
+ # only for entries nothing has asked for in a long time — or, once the cache is over its
162
+ # ceiling, for whatever has gone longest unread.
163
+ class GarbageCollection
164
+ # On by default, unlike syncing. A sweep costs no data and takes nothing anybody has
165
+ # used lately — where an unbounded cache costs storage on somebody's phone forever.
166
+ attr_accessor :enabled
167
+
168
+ # How long an entry may go untouched before it is collected. Anything read while a page
169
+ # is being stored is renewed, so this measures disuse rather than age.
170
+ attr_accessor :max_age
171
+
172
+ # How much the cache may hold, in bytes. Past it a sweep takes the least recently used
173
+ # entries first, until what is left fits — so a device that browses a great deal more
174
+ # than it revisits has a ceiling rather than only a deadline. nil is no ceiling.
175
+ #
176
+ # Measured over what a sweep is allowed to take, which is everything but the offline
177
+ # page's own assets and downloaded archives: counting a 300 MB download somebody chose
178
+ # to keep would empty the rest of the cache to make room for it.
179
+ attr_reader :max_size
180
+
181
+ def max_size=(bytes)
182
+ @max_size = validate_size(bytes)
183
+ end
184
+
185
+ # How long to leave between sweeps. Also how long the cache may sit over `max_size`,
186
+ # since that is when the ceiling is applied.
187
+ attr_accessor :interval
188
+
189
+ # What the offline settings page offers, as bytes. A ladder rather than a text field:
190
+ # somebody adjusting this on a phone is choosing roughly how much of their device to
191
+ # spend, not typing a number. The configured default is always among them, or an app
192
+ # that set 300 MB would have no way back to it once somebody had picked something else.
193
+ def size_choices
194
+ choices = [ 50, 100, 250, 500, 1024, 2048 ].map { |mb| mb * 1024 * 1024 }
195
+ choices << max_size if max_size
196
+
197
+ choices.uniq.sort
198
+ end
199
+
200
+ def initialize
201
+ @enabled = true
202
+ @max_age = 60 * 24 * 60 * 60
203
+ @max_size = 250 * 1024 * 1024
204
+ @interval = 24 * 60 * 60
205
+ end
206
+
207
+ # An entry read again while a page is stored is renewed rather than refetched — but not
208
+ # on every navigation, or every visit would rewrite every asset the page names. A
209
+ # quarter of the lifetime leaves three quarters of headroom before a collection.
210
+ def renew_after
211
+ return nil unless enabled && max_age
212
+
213
+ (max_age.to_i / 4).clamp(1, max_age.to_i)
214
+ end
215
+
216
+ private
217
+
218
+ # Bytes, and enough of them to be a cache rather than a rounding error. A megabyte
219
+ # ceiling is almost always a unit mistake — someone reaching for `250.megabytes` and
220
+ # writing `250` — and it would sweep away all but the last page or two visited.
221
+ def validate_size(bytes)
222
+ return nil if bytes.nil?
223
+
224
+ size = bytes.to_i
225
+ unless size >= 1024 * 1024
226
+ raise ArgumentError,
227
+ "Coldwire garbage_collection.max_size is in bytes and has to leave room for a " \
228
+ "page and what it loads, so it cannot be under a megabyte: #{bytes.inspect}"
229
+ end
230
+
231
+ size
232
+ end
233
+ end
234
+
145
235
  # WebKit has no Background Sync, Periodic Background Sync or Background Fetch, so nothing
146
236
  # can wake a worker. What a page load can do is hand work to one, which then runs on
147
237
  # without it — so syncing is triggered by an open page and paced, not scheduled.
@@ -167,8 +257,8 @@ module Coldwire
167
257
  def initialize
168
258
  @enabled = false
169
259
  @precache_urls = -> { [] }
170
- @interval = 6 * 60 * 60
171
- @max_age = 7 * 24 * 60 * 60
260
+ @interval = 24 * 60 * 60
261
+ @max_age = 30 * 24 * 60 * 60
172
262
  @concurrency = 4
173
263
  end
174
264
  end
@@ -78,6 +78,13 @@
78
78
  .coldwire-setting-title { display: block; font-weight: 600; }
79
79
  .coldwire-setting-note { display: block; margin-top: 0.15rem; font-size: 0.875rem; color: #6b7280; }
80
80
 
81
+ /* A setting whose control is a menu rather than a switch. The words take the slack and the
82
+ select asks only for its own width; on a narrow screen the two wrap rather than squeezing
83
+ the label down to one word a line. */
84
+ .coldwire-choice { display: flex; align-items: center; justify-content: space-between;
85
+ flex-wrap: wrap; gap: 0.75rem; cursor: pointer; }
86
+ .coldwire-choice > span:first-child { flex: 1 1 12rem; min-width: 0; }
87
+
81
88
  .coldwire-track { height: 0.375rem; width: 100%; overflow: hidden; border-radius: 9999px; background: #e5e7eb; }
82
89
  .coldwire-bar { height: 100%; width: 100%; border-radius: 9999px; background: #374151; transition: width 200ms; }
83
90
  .coldwire-pulse { animation: coldwire-fade 1.4s ease-in-out infinite; }
@@ -11,7 +11,7 @@ module Coldwire
11
11
  # The worker is served as one script but written as several. Concatenated rather than
12
12
  # imported, so the browser still fetches one file and function declarations hoist across
13
13
  # the whole of it.
14
- WORKER = %w[rules serve ranges archives inspect sync events].freeze
14
+ WORKER = %w[rules serve ranges archives inspect sync collect events].freeze
15
15
 
16
16
  class << self
17
17
  def worker
@@ -0,0 +1,153 @@
1
+ // Deleting is the one thing here with no way back. Everything else a worker gets wrong costs
2
+ // a round trip; a collection that runs on a dead connection costs pages that cannot be
3
+ // fetched again until one returns. So a sweep proves the network first, and only then takes
4
+ // what nothing has asked for in a long time — and, if the cache is still over its ceiling,
5
+ // whatever has gone longest unread until it fits.
6
+ let collecting = null
7
+
8
+ // The ceiling is a per-device choice, so it arrives with the request rather than being baked
9
+ // into the worker. Undefined is a page that has nothing to say about it — an older client, or
10
+ // one that never loaded the collector — and falls back to what the app configured.
11
+ function collectGarbage({ maxSize } = {}) {
12
+ const limit = maxSize === undefined ? COLLECT_MAX_SIZE : maxSize
13
+ // A sweep already running is the answer to this one too. It may be working to a ceiling
14
+ // that has just changed; the next sweep uses the new one, and a sweep is cheap to be late.
15
+ collecting = collecting || runCollection(limit).finally(() => { collecting = null })
16
+
17
+ return collecting
18
+ }
19
+
20
+ async function runCollection(maxSize) {
21
+ if (COLLECT_MAX_AGE === null && maxSize === null) return { ok: true, collected: 0, skipped: "disabled" }
22
+ if (forcedOffline) return { ok: true, offline: true }
23
+ if (!(await reachable())) return { ok: true, offline: true }
24
+
25
+ const cache = await caches.open(CACHE_NAME)
26
+ const keys = await cache.keys()
27
+ const spared = offlinePageAssets()
28
+ const now = Date.now() / 1000
29
+
30
+ let collected = 0
31
+ let kept = 0
32
+ // What the age pass left, which is exactly what the size pass may take from.
33
+ const survivors = []
34
+
35
+ for (const key of keys) {
36
+ if (isSpared(key, spared)) { kept += 1; continue }
37
+
38
+ const at = unixTimestamp(key.headers.get(TIMESTAMP_HEADER))
39
+ // No stamp at all is an entry from an older worker. Age unknown is not age exceeded, so
40
+ // it stays; the next time its page is stored it gets one.
41
+ if (COLLECT_MAX_AGE === null || at === null || now - at <= COLLECT_MAX_AGE) {
42
+ survivors.push({ key, at })
43
+ continue
44
+ }
45
+
46
+ if (await cache.delete(key)) collected += 1
47
+ }
48
+
49
+ const { evicted, bytes } = await trimToSize(cache, survivors, maxSize)
50
+
51
+ return { ok: true, collected, evicted, bytes, kept: kept + survivors.length - evicted, finishedAt: Date.now() }
52
+ }
53
+
54
+ // The ceiling. Age says when something has gone stale; this says how much of somebody's phone
55
+ // the cache may have, which on a device that browses far more than it revisits is the only one
56
+ // of the two that ever binds.
57
+ //
58
+ // Oldest read out first, until what is left fits. Last used is the closest thing the cache has
59
+ // to "least likely to be wanted back", and it is the same clock the age pass works from — an
60
+ // entry renewed because a page still loads it is young here too, so the thing being used is
61
+ // the last thing to go.
62
+ async function trimToSize(cache, entries, maxSize) {
63
+ if (maxSize === null || entries.length === 0) return { evicted: 0, bytes: 0 }
64
+
65
+ // One match per entry, and a body read for anything without a Content-Length. Affordable
66
+ // because a sweep runs on `interval` rather than on navigation — but only worth paying at
67
+ // all when there is a ceiling to measure against, which is why it sits behind the guard.
68
+ const sized = []
69
+ let total = 0
70
+
71
+ for (const entry of entries) {
72
+ // ignoreVary, as the settings page does when it lists the same entries: Rails answers
73
+ // HTML with `Vary: Accept`, and a measurement that quietly missed would read as an entry
74
+ // costing nothing and so never worth evicting.
75
+ const size = await entrySize(await cache.match(entry.key, { ignoreVary: true }))
76
+ total += size
77
+ sized.push({ key: entry.key, at: entry.at, size })
78
+ }
79
+
80
+ if (total <= maxSize) return { evicted: 0, bytes: total }
81
+
82
+ // An entry with no stamp is from an older worker, and has no place in an ordering by last
83
+ // use. Treated as the oldest thing here: it is the one entry we can say nothing has touched
84
+ // since this worker started stamping them.
85
+ sized.sort((a, b) => (a.at || 0) - (b.at || 0))
86
+
87
+ let evicted = 0
88
+
89
+ for (const entry of sized) {
90
+ if (total <= maxSize) break
91
+ if (!(await cache.delete(entry.key))) continue
92
+
93
+ total -= entry.size
94
+ evicted += 1
95
+ }
96
+
97
+ return { evicted, bytes: total }
98
+ }
99
+
100
+ // Never collected, whatever their age — and never counted against the ceiling either, since
101
+ // the ceiling has to measure the same thing a sweep can act on. A 300 MB archive counted in
102
+ // would empty everything else to make room for a file nothing is allowed to take:
103
+ //
104
+ // - what the offline page itself needs, which browsing never touches because nobody visits
105
+ // the offline page on purpose, and which is wanted precisely when there is no network
106
+ // - downloaded archives and the ranges of them, which somebody chose to spend a data plan
107
+ // on and which no amount of disuse makes safe to throw away
108
+ function isSpared(key, spared) {
109
+ const url = new URL(key.url)
110
+ if (url.searchParams.has(CHUNK_PARAM) || url.searchParams.has(RANGE_PARAM)) return true
111
+
112
+ return spared.has(url.href)
113
+ }
114
+
115
+ function offlinePageAssets() {
116
+ try {
117
+ return new Set(urlsFromHtml(OFFLINE_PAGE, self.location.origin + "/"))
118
+ } catch {
119
+ return new Set()
120
+ }
121
+ }
122
+
123
+ // navigator.onLine reports whether an interface is up, which a web view answers wrongly often
124
+ // enough to be worthless here. The probe is never intercepted, so this is a real request over
125
+ // a real connection or it is nothing.
126
+ async function reachable() {
127
+ try {
128
+ const response = await fetch(PROBE_PATH, { cache: "no-store", credentials: "same-origin" })
129
+
130
+ return response.ok
131
+ } catch {
132
+ return false
133
+ }
134
+ }
135
+
136
+ // An asset every page names is never refetched once held, so its stamp would date from the
137
+ // first page that pulled it in — and the collector would take the stylesheet the whole app is
138
+ // using. Renewing it on the way past is what keeps "untouched" meaning untouched.
139
+ //
140
+ // Rewritten from the cache rather than refetched, and only once it has aged, so an ordinary
141
+ // navigation costs a lookup and nothing more.
142
+ async function renew(cache, key) {
143
+ if (RENEW_AFTER === null) return
144
+
145
+ const at = unixTimestamp(key.headers.get(TIMESTAMP_HEADER))
146
+ if (at !== null && Date.now() / 1000 - at < RENEW_AFTER) return
147
+
148
+ const response = await cache.match(key)
149
+ if (!response) return
150
+
151
+ const managed = key.headers.get(MANAGED_HEADER) === "1"
152
+ await putFresh(cache, cacheKey(new Request(key.url, { method: "GET" }), { managed }), response)
153
+ }
@@ -70,6 +70,11 @@ self.addEventListener("message", (event) => {
70
70
  // page sees the run and not just whoever started it.
71
71
  if (type === "sync") return reply(syncManifest())
72
72
 
73
+ // Paced by a page the same way syncing is, and answered with whether it actually ran: a
74
+ // page must not record a sweep that never happened for want of a connection. The ceiling
75
+ // rides along, because it is the device's choice and the worker cannot read localStorage.
76
+ if (type === "collect") return reply(collectGarbage(event.data))
77
+
73
78
  // What a page missed by not listening yet, and whether it is still going on.
74
79
  if (type === "syncState") return reply({ ok: true, running: Boolean(syncing), last: lastSyncMessage })
75
80
 
@@ -112,11 +112,14 @@ async function storeResponse(cache, request, response) {
112
112
  await Promise.all(urls.map((href) => storeSubresource(cache, href)))
113
113
  }
114
114
 
115
- // Only what is missing. A digested address is stored once and then named by every page that
116
- // uses it, so after the first visit this settles down to a lookup and nothing else.
115
+ // Only what is missing is fetched. What is already held is renewed instead, so an asset every
116
+ // page names does not sit on the stamp of the first page that ever pulled it in and get
117
+ // collected while the whole app is still using it.
117
118
  async function storeSubresource(cache, href) {
118
119
  if (isNeverCached(new URL(href))) return
119
- if (await cache.match(href, MATCH_OPTIONS)) return
120
+
121
+ const [ key ] = await cache.keys(href, MATCH_OPTIONS)
122
+ if (key) return renew(cache, key)
120
123
 
121
124
  try {
122
125
  await fetchAndCache(cache, href)
@@ -88,7 +88,7 @@ module Coldwire
88
88
  def next_steps
89
89
  say ""
90
90
  say "Coldwire is mounted at /offline.", :green
91
- say "Set config.cache_identity if anyone signs in."
91
+ say "cache_identity uses current_user or Current.user when either is in scope."
92
92
  say "Turn on config.auto_sync to precache pages."
93
93
  end
94
94
 
@@ -8,15 +8,31 @@ Coldwire.configure do |config|
8
8
  sync.enabled = false # off by default: background fetching is somebody's data plan
9
9
  sync.precache_urls = -> { [] }
10
10
  # sync.precache_urls = -> { Article.published.map { |a| article_path(a) } }
11
- sync.interval = 6.hours
12
- sync.max_age = 7.days
11
+ sync.interval = 1.day
12
+ sync.max_age = 30.days
13
13
  sync.concurrency = 4
14
14
  end
15
15
 
16
+ # Taking back what nothing has used lately, so the cache does not grow forever. Runs only
17
+ # with a connection, and never takes the offline page's assets or a downloaded archive.
18
+ # Anything a stored page still loads is renewed, so age means disuse rather than age.
19
+ config.garbage_collection do |gc|
20
+ gc.enabled = true
21
+ gc.max_age = 60.days # keep comfortably longer than auto_sync.max_age
22
+ gc.max_size = 250.megabytes # over this the least recently read go first; nil for no ceiling
23
+ gc.interval = 1.day
24
+ end
25
+
16
26
  # Who the cache belongs to. Evaluated in the view. When it changes, the cache is dropped —
17
- # which is what makes signing out, and switching accounts, safe.
18
- config.cache_identity = -> { nil }
19
- # config.cache_identity = -> { current_user&.id }
27
+ # which is what makes signing out, and switching accounts, safe. Uses current_user or
28
+ # Current.user when either is around; otherwise nobody, and the cache stays put.
29
+ config.cache_identity = -> {
30
+ if respond_to?(:current_user)
31
+ current_user&.id
32
+ elsif defined?(Current) && Current.respond_to?(:user)
33
+ Current.user&.id
34
+ end
35
+ }
20
36
 
21
37
  # Where the worker registers at all. Evaluated in the view, so `request` and `current_user`
22
38
  # are both in scope. A page that does not register does not cache or sync.
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coldwire-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noreaster Group, Stuart Yamartino
@@ -26,7 +26,7 @@ dependencies:
26
26
  description: When your Hotwire wires go cold. A Rails engine that serves a Cache API
27
27
  service worker, precaches the pages you nominate, and falls back to an offline view
28
28
  Turbo will actually render. Built against Hotwire Native's rules, which are stricter
29
- than a browser's, so the same cache works in a plain Hotwire app and an installed
29
+ than a browser's, so the same cache works in a plain Hotwire web app and an installed
30
30
  PWA.
31
31
  email:
32
32
  - stuart@noreastergroup.com
@@ -56,6 +56,11 @@ files:
56
56
  - docs/README.md
57
57
  - docs/configuration.md
58
58
  - docs/how-it-works.md
59
+ - docs/images/coldwire-logo-dark.svg
60
+ - docs/images/coldwire-logo.svg
61
+ - docs/images/coldwire-mark-dark.svg
62
+ - docs/images/coldwire-mark.svg
63
+ - docs/images/noreaster-group.png
59
64
  - docs/images/offline-fallback.png
60
65
  - docs/images/offline-settings-cached.png
61
66
  - docs/images/offline-settings.png
@@ -63,6 +68,7 @@ files:
63
68
  - lib/coldwire-rails.rb
64
69
  - lib/coldwire.rb
65
70
  - lib/coldwire/client/api.js
71
+ - lib/coldwire/client/collect.js
66
72
  - lib/coldwire/client/cookie.js
67
73
  - lib/coldwire/client/forced.js
68
74
  - lib/coldwire/client/identity.js
@@ -77,6 +83,7 @@ files:
77
83
  - lib/coldwire/source.rb
78
84
  - lib/coldwire/version.rb
79
85
  - lib/coldwire/worker/archives.js
86
+ - lib/coldwire/worker/collect.js
80
87
  - lib/coldwire/worker/events.js
81
88
  - lib/coldwire/worker/inspect.js
82
89
  - lib/coldwire/worker/ranges.js