coldwire-rails 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 268be717f39cc2dcb3927c27c22256afcc81981facc029f9be277ef9c22c1ee0
4
- data.tar.gz: 03cc56cf120523536b582180dcb8366fc9194ca3ada5dca136abd44761cf7afb
3
+ metadata.gz: b5e35d2c0930a9eac9b39b725205a467bda9a982b1fb543f127e3f2a0cd3adbe
4
+ data.tar.gz: 2bb1ad7cb8ded065ad6820885ca8429c582f0bc688a9c2eb288328e85e02591d
5
5
  SHA512:
6
- metadata.gz: d2bf7e4ef38d0b7f3e25483995663263468f092904810a9aa48186c0dff1abfd5429890ff0aadc628fd14730d287da327e05daac39d07d92f0c66507f10fc4a2
7
- data.tar.gz: edea984f52957b16f2fd2f0664c9bcf262cf58735679b646b6c345730a4df65524f36e3a4fa3a8ff32ed17a697f0b030330ee670ac860a3fc0f3d06d6aefa120
6
+ metadata.gz: d15283073b65d9100749f0247c5ca4abebcdcfbbe429218499212ce67898b078a8222e6ebc533b8c1051607051e113ecf7ea12bfcc3273c16698859ce6c77e11
7
+ data.tar.gz: 9573b8cc389f6519222d606cdb1feb0a6019dc76024902d041f18697bb9d70fc149ddb88ce2c6ffe44da8267d25cbc232fe66758b4b8179c0eebc63fa63986ae
data/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [0.4.0]
6
+
7
+ - Updated the offline page to restructure storage and downloads.
8
+ - Updated the storage so when you change storage size it triggers a garbage collection.
9
+
10
+ ## [0.3.0]
11
+
12
+ - **`cache_origins` is `cacheable_hosts`**, and takes bare hosts: `"tiles.example.com"` rather
13
+ than `"https://tiles.example.com"`. The scheme was never carrying information — a worker
14
+ runs only on a secure page, and a secure page cannot fetch `http` — so it was a required
15
+ prefix with exactly one possible value. Requests are matched on a URL's `host`, so a port
16
+ belongs where it is not the default and `localhost:3001` matches that port and no other.
17
+ A scheme raises at boot, naming what to write instead.
18
+
19
+ ## [0.2.0]
20
+
21
+ - **Garbage collection** is on by default. `config.garbage_collection` periodically sweeps the cache, deleting entries that haven't been accessed for `max_age` (default: 60 days), and if the cache grows over `max_size` (default: 250 MB), it continues pruning the least recently accessed until the cache fits. Sweeps run only when confirmed online by pinging `probe_path`, since deletions are irreversible. Cached archives and offline page assets are never collected, and the age of an entry is renewed anytime it or its referenced subresources are accessed or stored. The size ceiling can be adjusted in the offline settings page and is remembered per device.
22
+ - The garbage collector runs safely and automatically; you generally do not need to configure it. But you can tune `max_age`, `max_size`, and `interval` to fit your app's needs.
23
+
24
+
3
25
  ## [0.1.0]
4
26
 
5
27
  First release. The API may still change before 1.0.
@@ -14,7 +36,10 @@ First release. The API may still change before 1.0.
14
36
  revisits everything. The ceiling is offered as a ladder of sizes on the offline settings
15
37
  page and remembered per device, since how much of a phone to spend is not something an app
16
38
  can know. It measures only what a sweep may take: downloaded archives are an opt-in spend
17
- of somebody's data plan, so they are neither counted nor evicted.
39
+ of somebody's data plan, so they are neither counted nor evicted. Choosing a size applies it
40
+ at once rather than at the next sweep, and that pass does not wait for a connection: it is a
41
+ deliberate instruction about somebody's own storage, which is how Clear cache has always
42
+ behaved.
18
43
  On by default, unlike syncing: it spends no data. A sweep runs only with a
19
44
  connection it has confirmed by pinging `probe_path`, because deleting is the one cache
20
45
  operation with no way back. Age is measured from last use, not from when an entry was
data/README.md CHANGED
@@ -25,7 +25,7 @@ Works the same in a plain web app, an installed PWA, or Hotwire Native.
25
25
 
26
26
  ## What you get
27
27
 
28
- - **[Setup in 30 seconds](docs/setup.md):** Instal gem, then `bin/rails coldwire:install`. The rest has working defaults.
28
+ - **[Setup in 30 seconds](docs/setup.md):** Install gem, then `bin/rails coldwire:install`. The rest has working defaults.
29
29
  - **[Cache as you go](docs/configuration.md#cache_as_you_go):** Defaults to all pages get cached as your navigate, customize to only cache certain parts of the app.
30
30
  - **[Auto Sycn precaching](docs/configuration.md#auto_sync):** Define urls that can be pre-cached automatically in the background.
31
31
  - **[Garbage collection](docs/configuration.md#garbage_collection):** Entries nothing has used in two months are swept, and anything over the storage ceiling goes least recently read first — so the cache doesn't grow forever. Only with a connection; anything a page still loads is renewed on the way past. People can set the ceiling for their own device on `/offline`.
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.2.0
1
+ 0.4.0
@@ -43,6 +43,7 @@ export default class extends Controller {
43
43
  "autoSync",
44
44
  "syncedAt",
45
45
  "syncStatus",
46
+ "syncDetails",
46
47
  "syncButton",
47
48
  "syncLabel",
48
49
  "search",
@@ -129,6 +130,7 @@ export default class extends Controller {
129
130
 
130
131
  if (data.state === "started") {
131
132
  this.syncRunning = true
133
+ this.revealSync()
132
134
  const retired = data.retired ? `, retired ${data.retired}` : ""
133
135
  this.setSyncStatus(data.pending
134
136
  ? `Syncing ${data.pending} file${data.pending === 1 ? "" : "s"}${retired}…`
@@ -143,6 +145,7 @@ export default class extends Controller {
143
145
  // A page that opened mid-run arrives here without ever having seen "started", so this
144
146
  // branch has to be able to put the page into the running state on its own.
145
147
  this.syncRunning = true
148
+ this.revealSync()
146
149
  this.toggleSyncing(true)
147
150
  if (this.hasProgressTarget) this.progressTarget.hidden = false
148
151
  // The bar carries the live count; the line above stays on the high-level "what".
@@ -214,6 +217,7 @@ export default class extends Controller {
214
217
  async syncNow(event) {
215
218
  event?.preventDefault()
216
219
  this.syncSettled = false
220
+ this.revealSync()
217
221
  // Reaching the worker takes a moment, and the ticker keeps ticking while it does.
218
222
  this.syncStarting = true
219
223
  this.toggleBusy(true)
@@ -243,6 +247,13 @@ export default class extends Controller {
243
247
  }
244
248
  }
245
249
 
250
+ // Everything a sync has to say lives behind the disclosure, so a run has to open it — a bar
251
+ // filling where nobody can see it is the same as no bar. Left open afterwards: somebody who
252
+ // has just watched a sync is the one person who might want the detail.
253
+ revealSync() {
254
+ if (this.hasSyncDetailsTarget) this.syncDetailsTarget.open = true
255
+ }
256
+
246
257
  setSyncStatus(text) {
247
258
  if (!this.hasSyncStatusTarget) return
248
259
 
@@ -250,24 +261,24 @@ export default class extends Controller {
250
261
  this.syncStatusTarget.hidden = !text
251
262
  }
252
263
 
264
+ // Inside the details, under the switch that says whether syncing is on at all. This says
265
+ // how often, and when it is off, that the button below still works.
253
266
  renderAutoSync() {
254
267
  if (!this.hasAutoSyncTarget) return
255
268
 
256
- // The switch beside this already says whether it is on, so this line carries the one
257
- // thing the switch cannot: how often.
258
269
  if (!this.autoSyncValue) {
259
- this.autoSyncTarget.textContent = "Automatic syncing is off"
270
+ this.autoSyncTarget.textContent = "Runs only when you press Sync now."
260
271
  return
261
272
  }
262
273
 
263
274
  if (!this.autoSyncOn()) {
264
- this.autoSyncTarget.textContent = "Off for this device"
275
+ this.autoSyncTarget.textContent = "Off for this device. Sync now still runs a pass."
265
276
  return
266
277
  }
267
278
 
268
279
  this.autoSyncTarget.textContent = this.syncIntervalValue > 0
269
- ? `Syncs every ${formatInterval(this.syncIntervalValue)}`
270
- : "On"
280
+ ? `Syncs every ${formatInterval(this.syncIntervalValue)}.`
281
+ : "On."
271
282
  }
272
283
 
273
284
  renderSyncedAt() {
@@ -428,6 +439,7 @@ export default class extends Controller {
428
439
  // What this page can answer by itself comes first: the cache is read directly and the
429
440
  // probe is one request. Behind the worker questions they waited out a registration that
430
441
  // may never arrive, and the page sat on "Checking…" with an empty list.
442
+ await this.loadSpared()
431
443
  await this.renderCache()
432
444
  await this.renderConnection()
433
445
 
@@ -835,73 +847,93 @@ export default class extends Controller {
835
847
  select.disabled = true
836
848
 
837
849
  try {
838
- // A lowered ceiling that waits for the next scheduled sweep reads as a setting that did
839
- // nothing. This is that same sweep, run now — and bound by the same rule, so with no
840
- // connection it stands down and the line under the bar says why.
841
- await this.collectNow()
850
+ // A ceiling that waits for the next scheduled sweep reads as a setting that did nothing,
851
+ // so it is applied here and now. Deliberate, so it does not stand down for want of a
852
+ // connection the way an automatic sweep does.
853
+ await this.applyCeiling()
842
854
  } finally {
843
855
  select.disabled = false
844
856
  }
845
857
  }
846
858
 
847
- async collectNow() {
848
- let result = null
859
+ async applyCeiling() {
860
+ // A trim over a thousand entries is a second or two, and a silent pause reads as a
861
+ // setting that did nothing.
862
+ if (this.hasUsageLabelTarget) this.usageLabelTarget.textContent = "Applying…"
849
863
 
850
864
  try {
851
- result = await sendToWorker("collect", { maxSize: this.store.maxSize() }, 60000)
852
- } catch {
853
- // No worker controlling this page yet. The cache is unchanged and the line already
854
- // says where it stands.
865
+ await sendToWorker("trim", { maxSize: this.store.maxSize() }, 60000)
866
+ } catch (error) {
867
+ // Say so. Swallowing this is what turned a stale worker — one registered before the app
868
+ // knew what a ceiling was, and still controlling this page until it is reloaded — into
869
+ // a setting that silently refused to bite.
870
+ if (this.hasUsageLabelTarget) {
871
+ this.usageLabelTarget.textContent = `Could not apply it: ${error.message}. Reload and try again.`
872
+ }
855
873
  return
856
874
  }
857
875
 
858
- // Nothing was swept, so the clock stays where it is and the next page load finds the
859
- // sweep still due — exactly as the head snippet treats a refused run.
860
- this.sweepWaiting = Boolean(result?.offline)
861
- if (!this.sweepWaiting) this.store.set(this.store.keys.collectedAt, Date.now())
862
-
876
+ await this.loadSpared()
863
877
  await this.renderCache()
864
878
  }
865
879
 
866
- // What the ceiling is measured against: everything but the downloads, which are an opt-in
867
- // spend of somebody's data plan and are never collected however full the cache gets — a
868
- // 300 MB archive counted here would show a bar pinned full of files no sweep can touch.
880
+ // The worker is the only thing that knows what the offline page needs, and those entries are
881
+ // never collected. Read once and kept: it does not change while this page is open.
882
+ async loadSpared() {
883
+ try {
884
+ const result = await sendToWorker("spared", {}, 5000)
885
+ this.spared = new Set(result?.urls || [])
886
+ } catch {
887
+ // An older worker, or none yet. Falling back to counting everything overstates what a
888
+ // sweep can take, which is the safer direction for a figure somebody sets a limit from.
889
+ this.spared = this.spared || new Set()
890
+ }
891
+ }
892
+
893
+ // What the ceiling is measured against, which has to be what a sweep can actually take, or
894
+ // the bar shows an overage nothing will ever bring down. Three exclusions:
895
+ //
896
+ // Downloads, an opt-in spend of somebody's data plan, are never collected however full the
897
+ // cache gets — a 300 MB archive counted here would pin the bar full of untouchable files.
898
+ // Their sizes are on their own rows, which is where somebody reclaims that space.
899
+ //
900
+ // The offline page's own assets, which the worker spares and names for us: working them out
901
+ // means parsing the offline page, and only the worker holds it.
869
902
  //
870
- // The worker also spares the offline page's own assets, which this cannot pick out of a
871
- // list of URLs. That is a handful of files, and erring towards the larger figure is the
872
- // right way round for a number somebody is deciding a limit from.
903
+ // Any cache but the worker's own. Bumping cache_name leaves the old one behind, and the
904
+ // worker only ever sweeps the one it is configured with.
873
905
  managedBytes(entries) {
874
- return this.totalBytes((entries || []).filter((entry) => !entry.download))
906
+ const swept = window.COLDWIRE?.cacheName
907
+ const spared = this.spared
908
+
909
+ return this.totalBytes((entries || []).filter((entry) =>
910
+ !entry.download &&
911
+ !(spared && spared.has(entry.url)) &&
912
+ (!swept || !entry.cache || entry.cache === swept)))
875
913
  }
876
914
 
877
915
  renderStorage() {
878
916
  if (!this.hasUsageLabelTarget) return
879
917
 
880
918
  const limit = this.store.maxSize()
881
- const used = this.managedBytes(this.entries)
919
+ const managed = this.managedBytes(this.entries)
882
920
 
883
921
  if (this.hasUsageTarget) this.usageTarget.hidden = limit === null
884
922
 
885
923
  if (limit === null) {
886
- this.usageLabelTarget.textContent = `${formatBytes(used)} of cached pages, with no limit set.`
887
- return
888
- }
924
+ this.usageLabelTarget.textContent = `${formatBytes(managed)} of cached pages, with no limit set.`
925
+ } else {
926
+ const percent = Math.round(Math.min(managed / limit, 1) * 100)
927
+ if (this.hasUsageBarTarget) this.usageBarTarget.style.width = `${percent}%`
928
+ if (this.hasUsageTarget) this.usageTarget.setAttribute("aria-valuenow", String(percent))
889
929
 
890
- const percent = Math.round(Math.min(used / limit, 1) * 100)
891
- if (this.hasUsageBarTarget) this.usageBarTarget.style.width = `${percent}%`
892
- if (this.hasUsageTarget) this.usageTarget.setAttribute("aria-valuenow", String(percent))
893
-
894
- const parts = [ `${formatBytes(used)} of ${formatLimit(limit)}` ]
895
- if (used > limit) {
896
- // Over the ceiling is not a fault and not a promise of instant deletion: a sweep needs
897
- // a connection, and says so rather than leaving somebody watching a bar that will not
898
- // move.
899
- parts.push(this.sweepWaiting || this.online === false
900
- ? "over, waiting for a connection to sweep"
901
- : "over, the oldest go on the next sweep")
902
- }
930
+ const parts = [ `${formatBytes(managed)} of ${formatLimit(limit)} used by cached pages` ]
931
+ // Choosing a ceiling applies it on the spot, so this is what is left between sweeps: a
932
+ // cache that has grown past a ceiling nobody has touched since. The next sweep takes it.
933
+ if (managed > limit) parts.push("over, the oldest go on the next sweep")
903
934
 
904
- this.usageLabelTarget.textContent = `${parts.join(" · ")}.`
935
+ this.usageLabelTarget.textContent = `${parts.join(" · ")}.`
936
+ }
905
937
  }
906
938
 
907
939
  renderEntries() {
@@ -81,7 +81,7 @@
81
81
  <span>
82
82
  <span class="coldwire-setting-title">Offline support</span>
83
83
  <span class="coldwire-setting-note">
84
- Cache pages so they work without a connection.
84
+ Enable page caching so they work without a connection.
85
85
  </span>
86
86
  </span>
87
87
  </label>
@@ -102,11 +102,84 @@
102
102
  </span>
103
103
  </span>
104
104
  </label>
105
+
106
+ <%# Syncing sits with the other two switches rather than in a card of its own: all three
107
+ are the same kind of thing, a choice this device makes about how Coldwire behaves. %>
108
+ <hr>
109
+
110
+ <% if Coldwire.config.auto_sync.enabled %>
111
+ <%# Only where the app configured automatic syncing at all: a switch for something that
112
+ was never going to happen is a switch that lies. %>
113
+ <label class="coldwire-setting">
114
+ <span class="coldwire-switch coldwire-switch--on">
115
+ <input type="checkbox"
116
+ data-coldwire-cache-target="autoSyncToggle"
117
+ data-action="change->coldwire-cache#toggleAutoSync">
118
+ <span aria-hidden="true"></span>
119
+ </span>
120
+ <span>
121
+ <span class="coldwire-setting-title">Sync on its own</span>
122
+ <span class="coldwire-setting-note">
123
+ Automatically back up pages in the background.
124
+ </span>
125
+ </span>
126
+ </label>
127
+ <% else %>
128
+ <p class="coldwire-facts" style="margin-top: 0;">
129
+ Automatically back up pages in the background.
130
+ <strong>Sync now</strong> runs a pass immediately.
131
+ </p>
132
+ <% end %>
133
+
134
+ <%# The clock, the button and the bar behind a disclosure. They are worth having, and
135
+ worth going to: under a switch you are not using they are three lines of furniture
136
+ on every visit. It opens itself while a sync runs, so progress is never hidden
137
+ behind a tap nobody knew to make. %>
138
+ <details class="coldwire-sync" data-coldwire-cache-target="syncDetails">
139
+ <summary><span>Sync details</span></summary>
140
+
141
+ <div class="coldwire-sync-body">
142
+ <%# How often, which the switch cannot show and the note above no longer says. %>
143
+ <p class="coldwire-facts" data-coldwire-cache-target="autoSync">Checking…</p>
144
+ <p class="coldwire-facts" data-coldwire-cache-target="syncedAt">Never synced</p>
145
+
146
+ <%# Empty until a sync has something to say, so the card does not carry a permanent
147
+ "Idle" that reads like debug output left in by accident. %>
148
+ <p class="coldwire-facts" data-coldwire-cache-target="syncStatus" hidden></p>
149
+
150
+ <div class="coldwire-actions coldwire-actions--spaced">
151
+ <button type="button"
152
+ data-coldwire-cache-target="syncButton"
153
+ class="coldwire-button"
154
+ data-action="click->coldwire-cache#syncNow">
155
+ <span class="coldwire-spinner" data-coldwire-cache-target="spinner" aria-hidden="true" hidden></span>
156
+ <span data-coldwire-cache-target="syncLabel">Sync now</span>
157
+ </button>
158
+ </div>
159
+
160
+ <div style="margin-top: 0.75rem;"
161
+ role="progressbar"
162
+ aria-valuemin="0"
163
+ aria-valuemax="100"
164
+ data-coldwire-cache-target="progress"
165
+ hidden>
166
+ <div class="coldwire-track">
167
+ <div class="coldwire-bar" data-coldwire-cache-target="progressBar"></div>
168
+ </div>
169
+ <div class="coldwire-progress-label" data-coldwire-cache-target="progressLabel"></div>
170
+ </div>
171
+ </div>
172
+ </details>
105
173
  </div>
106
174
  </div>
107
175
 
108
176
  <div data-coldwire-cache-target="whenOn">
109
177
  <% gc = Coldwire.config.garbage_collection %>
178
+ <% archives = Coldwire.config.cache_archives %>
179
+ <%# Two cards, because they answer two questions. This one is what the app keeps as you
180
+ browse and how much of it you will allow; the next is what you chose to keep yourself.
181
+ One card had the first card's figure sitting a finger's width from files it does not
182
+ count, which read as an error in the figure. %>
110
183
  <% if gc.enabled %>
111
184
  <%# What the cache is allowed to grow to on this device. The choice is remembered here the
112
185
  way Force offline and Sync on its own are — the app sets the starting point, the person
@@ -150,94 +223,61 @@
150
223
  </div>
151
224
 
152
225
  <p class="coldwire-facts" data-coldwire-cache-target="usageLabel">Reading the cache…</p>
226
+
227
+ <% if archives.any? %>
228
+ <%# Said where the question comes up. Somebody reading a figure that does not match the
229
+ one in the header is owed the reason on the spot, not a card further down. This
230
+ line is about what the figure counts; what becomes of a download is the other
231
+ card's business, and saying it twice made both cards read as hedging. %>
232
+ <p class="coldwire-downloads-note">
233
+ Cached pages only. Downloads are counted separately, under Downloads below.
234
+ </p>
235
+ <% end %>
153
236
  </div>
154
237
  <% end %>
155
238
 
156
- <div class="coldwire-card">
157
- <h2>Auto Sync</h2>
239
+ <% if archives.any? %>
240
+ <%# One row per configured file, each reporting its own size. The words are the app's — the
241
+ page knows only that these are large, optional, and worth keeping. %>
242
+ <div class="coldwire-card coldwire-downloads" data-coldwire-cache-target="archives">
243
+ <h2>Downloads</h2>
158
244
 
159
- <% if Coldwire.config.auto_sync.enabled %>
160
- <%# Only where the app configured automatic syncing at all: a switch for something that
161
- was never going to happen is a switch that lies. The card leads with it and lets its
162
- note do the explaining, the way Force offline does. %>
163
- <label class="coldwire-setting">
164
- <span class="coldwire-switch coldwire-switch--on">
165
- <input type="checkbox"
166
- data-coldwire-cache-target="autoSyncToggle"
167
- data-action="change->coldwire-cache#toggleAutoSync">
168
- <span aria-hidden="true"></span>
169
- </span>
170
- <span>
171
- <span class="coldwire-setting-title">Sync on its own</span>
172
- <span class="coldwire-setting-note">
173
- Automatically runs in the background backing up pages to be used offline.
174
- </span>
175
- </span>
176
- </label>
177
- <% else %>
178
- <p class="coldwire-facts" style="margin-top: 0;">
179
- Automatically runs in the background backing up pages to be used offline.
180
- <strong>Sync now</strong> runs that pass immediately.
181
- </p>
182
- <% end %>
183
-
184
- <p class="coldwire-facts">
185
- <span data-coldwire-cache-target="autoSync">Checking…</span><br>
186
- <span data-coldwire-cache-target="syncedAt">Never synced</span>
187
- </p>
188
-
189
- <%# Empty until a sync has something to say, so the card does not carry a permanent
190
- "Idle" that reads like debug output left in by accident. %>
191
- <p class="coldwire-facts" data-coldwire-cache-target="syncStatus" hidden></p>
192
-
193
- <div class="coldwire-actions coldwire-actions--spaced">
194
- <button type="button"
195
- data-coldwire-cache-target="syncButton"
196
- class="coldwire-button"
197
- data-action="click->coldwire-cache#syncNow">
198
- <span class="coldwire-spinner" data-coldwire-cache-target="spinner" aria-hidden="true" hidden></span>
199
- <span data-coldwire-cache-target="syncLabel">Sync now</span>
200
- </button>
201
- </div>
245
+ <%# The name and what you can do about it on one line, the words underneath. The button
246
+ sits hard right, where the eye goes for the action, and what is happening reads under
247
+ both when a download is running. %>
248
+ <% archives.each do |archive| %>
249
+ <div class="coldwire-archive" data-archive-url="<%= archive[:url] %>">
250
+ <div class="coldwire-archive-head">
251
+ <div class="coldwire-archive-title"><%= archive[:title] %></div>
202
252
 
203
- <div style="margin-top: 0.75rem;"
204
- role="progressbar"
205
- aria-valuemin="0"
206
- aria-valuemax="100"
207
- data-coldwire-cache-target="progress"
208
- hidden>
209
- <div class="coldwire-track">
210
- <div class="coldwire-bar" data-coldwire-cache-target="progressBar"></div>
211
- </div>
212
- <div class="coldwire-progress-label" data-coldwire-cache-target="progressLabel"></div>
213
- </div>
214
- </div>
253
+ <div class="coldwire-actions coldwire-archive-actions">
254
+ <button type="button" class="coldwire-button"
255
+ data-url="<%= archive[:url] %>"
256
+ data-action="click->coldwire-cache#downloadArchive">
257
+ <span class="coldwire-spinner" data-archive-spinner aria-hidden="true" hidden></span>
258
+ <span data-archive-download-label>Download</span>
259
+ </button>
260
+ <%# Unlabelled but for its shape, as the trash is everywhere else on this page,
261
+ so the pair fits beside the title rather than under it. %>
262
+ <button type="button"
263
+ class="coldwire-button coldwire-icon-button coldwire-button--danger"
264
+ title="Delete this download"
265
+ aria-label="Delete <%= archive[:title] %>"
266
+ data-url="<%= archive[:url] %>"
267
+ data-action="click->coldwire-cache#removeArchive"
268
+ data-archive-remove hidden>
269
+ <svg viewBox="0 0 24 24" aria-hidden="true">
270
+ <path stroke-linecap="round" stroke-linejoin="round" d="<%= trash_path %>" />
271
+ </svg>
272
+ </button>
273
+ </div>
274
+ </div>
215
275
 
216
- <% if Coldwire.config.cache_archives.any? %>
217
- <%# One row per configured file. The words are the app's — the page knows only that these
218
- are large, optional, and worth keeping. %>
219
- <div class="coldwire-card" data-coldwire-cache-target="archives">
220
- <% Coldwire.config.cache_archives.each do |archive| %>
221
- <div class="coldwire-archive" data-archive-url="<%= archive[:url] %>">
222
- <div class="coldwire-archive-title"><%= archive[:title] %></div>
276
+ <%# The words run the full width under both, rather than in a column the button has
277
+ squeezed to three syllables a line. %>
223
278
  <% if archive[:description].present? %>
224
279
  <p class="coldwire-facts"><%= archive[:description] %></p>
225
280
  <% end %>
226
- <div class="coldwire-actions">
227
- <button type="button" class="coldwire-button"
228
- data-url="<%= archive[:url] %>"
229
- data-action="click->coldwire-cache#downloadArchive">
230
- <span class="coldwire-spinner" data-archive-spinner aria-hidden="true" hidden></span>
231
- <span data-archive-download-label>Download</span>
232
- </button>
233
- <button type="button" class="coldwire-button coldwire-button--danger"
234
- data-url="<%= archive[:url] %>"
235
- data-action="click->coldwire-cache#removeArchive"
236
- data-archive-remove hidden>
237
- Delete
238
- </button>
239
- </div>
240
-
241
281
  <p class="coldwire-facts coldwire-archive-meta" data-archive-status>Checking…</p>
242
282
 
243
283
  <div style="margin-top: 0.75rem;" role="progressbar" aria-valuemin="0" aria-valuemax="100"
@@ -247,6 +287,15 @@
247
287
  </div>
248
288
  </div>
249
289
  <% end %>
290
+
291
+ <% if gc.enabled %>
292
+ <%# The reassurance belongs with the files it is about: a 300 MB download sitting under
293
+ a storage limit raises the question of whether the app is about to take it back. %>
294
+ <p class="coldwire-downloads-note">
295
+ Yours until you delete them. <strong>Keep at most</strong> never counts a download,
296
+ and never clears one to make room.
297
+ </p>
298
+ <% end %>
250
299
  </div>
251
300
  <% end %>
252
301
 
@@ -21,7 +21,7 @@ const CACHE_AS_YOU_GO = compileRules(<%= raw Coldwire.cache_rules(Coldwire.confi
21
21
  const NEVER_CACHE = compileRules(<%= raw Coldwire.cache_rules(Coldwire.config.never_cache).to_json %>)
22
22
  // Origins besides ours that may be cached at all, and the URLs whose byte ranges are cached
23
23
  // piece by piece.
24
- const CACHE_ORIGINS = <%= raw Coldwire.config.cache_origins.to_json %>
24
+ const CACHEABLE_HOSTS = <%= raw Coldwire.config.cacheable_hosts.to_json %>
25
25
  const CACHE_RANGES = compileRules(<%= raw Coldwire.cache_rules(Coldwire.config.cache_ranges).to_json %>)
26
26
  // A range entry is stored under its own key, with the range in the query and the archive's
27
27
  // total size in a header — the two things needed to rebuild a 206 that was never storable.
@@ -36,7 +36,7 @@ Coldwire.configure do |config|
36
36
  config.never_cache = []
37
37
  config.never_intercept = [ "/up" ]
38
38
 
39
- config.cache_origins = []
39
+ config.cacheable_hosts = []
40
40
  config.cache_ranges = []
41
41
  config.cache_archives = []
42
42
 
@@ -71,7 +71,7 @@ down with it.
71
71
  | [`cache_as_you_go`](#cache_as_you_go) | `["/*"]` | Pages stored as somebody browses. `/*` is everything |
72
72
  | [`never_cache`](#never_cache) | `[]` | Never stored, by any route in. The one veto |
73
73
  | [`never_intercept`](#never_intercept) | `["/up"]` | Paths the worker does not touch at all |
74
- | [`cache_origins`](#cache_origins) | `[]` | Other origins the worker may cache |
74
+ | [`cacheable_hosts`](#cacheable_hosts) | `[]` | Other hosts the worker may cache |
75
75
  | [`cache_ranges`](#cache_ranges) | `[]` | URLs whose `Range` requests are cached piece by piece |
76
76
  | [`cache_archives`](#cache_archives) | `[]` | Large files somebody can choose to download |
77
77
  | [`ignore_query_params`](#ignore_query_params) | `true` | Treat `/map` and `/map?zoom=9` as one page |
@@ -203,7 +203,8 @@ collect and the cache grows until the browser evicts the lot.
203
203
  **Only ever with a connection.** Deleting is the one cache operation with no way back:
204
204
  whatever goes is gone until the network can be reached again. So a sweep pings
205
205
  [`probe_path`](#probe_path) first and stands down if it cannot be reached, and stands down
206
- under force offline. `navigator.onLine` is not consulted — a web view answers it wrongly often
206
+ under force offline. The one exception is somebody choosing a storage limit on the settings
207
+ page, which applies right away: see [`max_size`](#garbage_collectionmax_size). `navigator.onLine` is not consulted — a web view answers it wrongly often
207
208
  enough to be worthless for a decision this expensive to get wrong.
208
209
 
209
210
  **Untouched, not old.** Age is measured from when an entry was last *used*, not when it was
@@ -268,15 +269,27 @@ Measured over what a sweep is allowed to take, which is everything but the offli
268
269
  assets and downloaded archives. Counting a 300 MB download somebody deliberately kept would
269
270
  empty the rest of the cache to make room for a file no sweep may touch.
270
271
 
272
+ Which leaves two figures on the settings page that do not match: the header counts everything
273
+ on the device, the limit governs only part of it. So Storage and Downloads are separate cards,
274
+ each carrying one line about the other. Storage says its figure is cached pages only and that
275
+ downloads are counted below; Downloads says the limit never counts a download and never clears
276
+ one to make room.
277
+
271
278
  The ceiling is applied when a sweep runs, so [`interval`](#garbage_collectioninterval) is also
272
279
  how long the cache may sit over it. Lower the interval if a tighter bound matters more than
273
280
  the work.
274
281
 
275
- **People can change it.** The offline settings page offers a ladder of sizes — the configured
276
- default always among them — and the choice is remembered in `localStorage` for that device,
277
- the way Force offline and the Auto Sync switch are. It travels to the worker with each sweep,
278
- since a worker cannot read `localStorage`. Changing it there sweeps immediately rather than
279
- waiting out the interval.
282
+ **People can change it.** The offline settings page offers a ladder of sizes, the configured
283
+ default always among them, and the choice is remembered in `localStorage` for that device the
284
+ way Force offline and the Auto Sync switch are. It travels to the worker with each sweep,
285
+ since a worker cannot read `localStorage`.
286
+
287
+ Picking a size applies it on the spot: if the cache is over the new ceiling, the least
288
+ recently read entries go immediately, until it fits. That one pass does **not** wait for a
289
+ connection, and runs under Force offline, because it is a deliberate instruction about
290
+ somebody's own storage rather than an automatic sweep. Clear cache has always worked the same
291
+ way. Only the ceiling is applied; age collection still waits for a connection it has
292
+ confirmed.
280
293
 
281
294
  ### `garbage_collection.interval`
282
295
 
@@ -497,22 +510,28 @@ config.never_cache = [ %r{^/admin(/|$)}, %r{^/users/[^/]+/edit$} ]
497
510
 
498
511
  ---
499
512
 
500
- ## `cache_origins`
513
+ ## `cacheable_hosts`
501
514
 
502
515
  **Default:** `[]`
503
516
 
504
- Origins besides your own that the worker may cache. Each has to send CORS headers naming
505
- your app, or the response arrives opaque — status 0, no headers, no readable body — and
506
- there is nothing worth storing. Ranged sources must also expose `Content-Range`.
517
+ The hosts besides your own that the worker may cache. Each has to send CORS headers naming
518
+ your app, or the response arrives opaque — status 0, no headers, no readable body — and there
519
+ is nothing worth storing. Ranged sources must also expose `Content-Range`.
507
520
 
508
521
  ```ruby
509
- config.cache_origins = [ "https://tiles.example.com" ]
522
+ config.cacheable_hosts = [ "tiles.example.com", "localhost:3001" ]
510
523
  ```
511
524
 
512
- Bare origins only: a scheme and a host, no path, no trailing slash. Anything else raises at
513
- boot, because a malformed origin silently fails to match a request's origin.
525
+ Just the host. No scheme: a worker runs only on a secure page, and a secure page cannot fetch
526
+ `http`, so there was never a second scheme for one to tell apart. Writing one raises at boot
527
+ rather than being stripped — a scheme quietly accepted is a config that looks migrated and is
528
+ not, and the error names what to write instead.
514
529
 
515
- Cross-origin requests are passed through unless the origin is listed here.
530
+ A port only where it is not the default. A request is matched on its URL's `host`, against
531
+ exactly what you wrote: `localhost:3001` matches that port and no other, and a subdomain is a
532
+ different host.
533
+
534
+ Requests to anywhere else are passed straight through — not intercepted, not stored.
516
535
 
517
536
  ---
518
537
 
@@ -530,7 +549,7 @@ config.cache_ranges = [ "/tiles/*", %r{\.pmtiles$} ]
530
549
 
531
550
  Patterns match the URL path, same as the other lists — not the full URL. A cross-origin
532
551
  tile at `https://tiles.example.com/basemap.pmtiles` is allowed only when that origin is in
533
- [`cache_origins`](#cache_origins) *and* its path matches a rule here.
552
+ [`cacheable_hosts`](#cacheable_hosts) *and* its path matches a rule here.
534
553
 
535
554
  This pairs with [`cache_archives`](#cache_archives): `cache_ranges` caches the slices
536
555
  actually read, so the places you have already opened work offline, and downloading the
@@ -542,10 +561,19 @@ archive is how the rest does.
542
561
 
543
562
  **Default:** `[]`
544
563
 
545
- Large files somebody can choose to keep — a tile archive, an audio guide, a reference PDF.
546
- Nothing downloads on its own: hundreds of megabytes over somebody's connection is their
547
- decision. The offline settings page shows **Download**, then **Download again** and **Delete** once it
548
- is on the device, or **Resume** where a download stopped part way.
564
+ Large files somebody can choose to keep: a tile archive, an audio guide, a reference PDF.
565
+ Nothing downloads on its own, because hundreds of megabytes over somebody's connection is
566
+ their decision. Each appears in the Downloads card on the offline settings page: the title
567
+ with the button hard right, **Download**, then **Download again** once it is on the device or
568
+ **Resume** where one stopped part way, with a trash button beside it to remove it. The
569
+ description, the size and the progress of a running download read underneath, at the full width
570
+ of the card.
571
+
572
+ They live in that card rather than one of their own because they answer the same question the
573
+ storage limit does: what is on this device, and how much of it do you want to keep? What the
574
+ limit governs is at the top, these sit under it with their own sizes, and the card ends on a
575
+ total that reconciles with the header. A download is never deleted to make room and never
576
+ counted against the limit, so removing one is something only the person who chose it does.
549
577
 
550
578
  ```ruby
551
579
  config.cache_archives = [
@@ -561,7 +589,7 @@ Files arrive in 8 MB chunks, which is what makes a dropped connection cost secon
561
589
  of the whole download. A `Range` request against a downloaded archive is answered by
562
590
  slicing the chunks.
563
591
 
564
- If the file lives on another origin, list that origin in [`cache_origins`](#cache_origins).
592
+ If the file lives on another host, list it in [`cacheable_hosts`](#cacheable_hosts).
565
593
 
566
594
  ---
567
595
 
data/docs/setup.md CHANGED
@@ -135,9 +135,11 @@ redirects to login, so nothing about the online flow changes.
135
135
  Mounted at the engine root — `/offline` with the mount above. It inherits your
136
136
  `ApplicationController`, so it picks up your layout, authentication, and helpers.
137
137
 
138
- This is the page people use to turn offline support on or off, see connection status, download
139
- archives, turn auto-sync off for this device, set how much storage the cache may use, force
140
- offline, and manage what is cached.
138
+ This is the page people use to turn offline support on or off, see connection status, turn
139
+ auto-sync off for this device, force offline, and manage what is cached. The three switches
140
+ sit together in the status card at the top, since each is a choice this device makes about how
141
+ Coldwire behaves. Storage and Downloads are separate cards, since the storage limit governs
142
+ cached pages and never touches a download.
141
143
  Turning offline support off asks first, then deletes what is stored and hides the rest of the
142
144
  page. It sets `content_for :title` to `"Offline settings"` — yield that in your layout's
143
145
  `<title>` (and any native title bar that reads it) rather than expecting an on-page heading.
@@ -176,7 +178,7 @@ app-bound mode and takes service workers with it.
176
178
  ## Optional next steps
177
179
 
178
180
  - Restrict what browsing stores with [`cache_as_you_go`](configuration.md#cache_as_you_go)
179
- - Nominate other origins or `Range` URLs with [`cache_origins`](configuration.md#cache_origins)
181
+ - Nominate other hosts or `Range` URLs with [`cacheable_hosts`](configuration.md#cacheable_hosts)
180
182
  and [`cache_ranges`](configuration.md#cache_ranges)
181
183
  - Offer large files for download with [`cache_archives`](configuration.md#cache_archives)
182
184
  - Override the offline fallback by creating
@@ -88,13 +88,19 @@ module Coldwire
88
88
  # cached pages hold whatever the previous session could see.
89
89
  attr_writer :cache_identity
90
90
 
91
- # Origins besides your own that the worker may cache. Each has to send CORS headers naming
91
+ # The hosts besides your own that the worker may cache. Each has to send CORS headers naming
92
92
  # your app, or the response arrives opaque — status 0, no headers, no readable body — and
93
93
  # there is nothing worth storing. Ranged sources must also expose Content-Range.
94
- attr_reader :cache_origins
94
+ #
95
+ # config.cacheable_hosts = [ "tiles.example.com" ]
96
+ #
97
+ # A hostname, with a port only where it is not the default — which is what a URL's `host`
98
+ # reads as. No scheme: a worker runs only on a secure page, and a secure page cannot fetch
99
+ # http, so there was never a second scheme for one to tell apart.
100
+ attr_reader :cacheable_hosts
95
101
 
96
- def cache_origins=(origins)
97
- @cache_origins = Array(origins).map { |origin| validate_origin(origin) }
102
+ def cacheable_hosts=(hosts)
103
+ @cacheable_hosts = Array(hosts).map { |host| validate_host(host) }
98
104
  end
99
105
 
100
106
  # URLs whose Range requests are cached piece by piece, keyed by the range — for a large
@@ -278,7 +284,7 @@ module Coldwire
278
284
  @register_if = -> { true }
279
285
  @caching_enabled_by_default = true
280
286
  @cache_identity = -> { nil }
281
- @cache_origins = []
287
+ @cacheable_hosts = []
282
288
  @cache_ranges = []
283
289
  @cache_archives = []
284
290
  end
@@ -330,19 +336,27 @@ module Coldwire
330
336
 
331
337
  # An origin and nothing more: no path, no trailing slash. Anything else silently fails to
332
338
  # match a request's origin, which is the same quiet failure as a malformed path pattern.
333
- def validate_origin(origin)
334
- value = origin.to_s
335
-
336
- begin
337
- uri = URI.parse(value)
338
- rescue URI::InvalidURIError
339
- uri = nil
339
+ # A host, and a port only where it is not the default — which is exactly what a URL's
340
+ # `host` reads as, so the worker compares what you wrote against what it is handed.
341
+ HOST = /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?::\d+)?\z/
342
+ SCHEME = %r{\A[a-z][a-z0-9+.\-]*://}
343
+
344
+ # Raised rather than quietly stripped. Everyone arriving here is renaming `cache_origins`,
345
+ # and a scheme silently accepted is a config that looks migrated and is not — the next
346
+ # person to read it learns the wrong shape.
347
+ def validate_host(host)
348
+ value = host.to_s.strip.downcase
349
+
350
+ if value.match?(SCHEME)
351
+ raise ArgumentError,
352
+ "Coldwire cacheable_hosts takes a host with no scheme — " \
353
+ "#{value.sub(SCHEME, '').chomp('/').inspect} rather than #{host.inspect}"
340
354
  end
341
355
 
342
- unless uri&.scheme && uri.host && uri.path.to_s.empty? && uri.query.nil?
356
+ unless value.match?(HOST)
343
357
  raise ArgumentError,
344
- "Coldwire cache_origins takes bare origins like " \
345
- "\"https://tiles.example.com\": #{origin.inspect}"
358
+ "Coldwire cacheable_hosts takes bare hosts like " \
359
+ "\"tiles.example.com\": #{host.inspect}"
346
360
  end
347
361
 
348
362
  value
@@ -114,14 +114,43 @@
114
114
  .coldwire-input:focus-visible, .coldwire-select:focus-visible { outline: 2px solid #6b7280;
115
115
  outline-offset: 1px; }
116
116
 
117
+ /* The sync clock, its button and its bar, behind a disclosure: worth having, worth going to,
118
+ and three lines of furniture under a switch you are not currently using. Quiet like the
119
+ Inspect cache summary, because it is the same kind of thing. */
120
+ .coldwire-sync { margin-top: 0.9rem; }
121
+ .coldwire-sync > summary { display: flex; align-items: center; gap: 0.35rem; cursor: pointer;
122
+ list-style: none; font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em;
123
+ text-transform: uppercase; color: #9ca3af; }
124
+ .coldwire-sync > summary::-webkit-details-marker { display: none; }
125
+ .coldwire-sync > summary::after { content: ""; width: 0.8rem; height: 0.8rem; flex-shrink: 0;
126
+ background: var(--coldwire-caret) no-repeat center / contain; opacity: 0.7;
127
+ transition: transform 160ms ease; }
128
+ .coldwire-sync[open] > summary::after { transform: rotate(180deg); }
129
+ .coldwire-sync > summary:focus-visible { outline: 2px solid #6b7280; outline-offset: 2px;
130
+ border-radius: 0.25rem; }
131
+ /* The first line inside sets its own spacing from the summary. */
132
+ .coldwire-sync-body > .coldwire-facts:first-child { margin-top: 0.5rem; }
133
+
134
+ /* The line each card carries about the other: what the limit does not count, and what the
135
+ limit will never take. Quieter than the figures they qualify, and always under them. */
136
+ .coldwire-downloads-note { margin: 0.9rem 0 0; padding-top: 0.75rem;
137
+ border-top: 1px solid #e5e7eb; font-size: 0.8125rem; line-height: 1.5; color: #9ca3af; }
138
+ .coldwire-downloads-note strong { color: #6b7280; font-weight: 600; }
139
+
117
140
  /* The rule separates one download from the next; the first has nothing above it. */
118
- .coldwire-archive { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb; }
119
- .coldwire-archive:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
120
- .coldwire-archive-title { font-size: 0.9375rem; font-weight: 600; }
121
- /* The buttons used to sit under a status line that spaced them; they lead now. */
122
- .coldwire-archive .coldwire-actions { margin-top: 0.75rem; }
123
- /* What pressing them got you, reading under them rather than above. */
124
- .coldwire-archive-meta { margin-top: 0.6rem; font-size: 0.8125rem; }
141
+ .coldwire-archive { margin-top: 0.9rem; padding-top: 0.9rem; border-top: 1px solid #e5e7eb; }
142
+ .coldwire-archive:first-of-type { margin-top: 0; padding-top: 0; border-top: 0; }
143
+ /* The name and the action on one line, the words underneath at full width. The buttons sit
144
+ hard right, where the eye goes for the action, and keep their width whatever the title does:
145
+ a long title gives way instead, because a button that wraps to two lines is worse than a
146
+ name that ellipsises. */
147
+ .coldwire-archive-head { display: flex; align-items: center; justify-content: space-between;
148
+ gap: 0.75rem; }
149
+ .coldwire-archive-actions { flex: 0 0 auto; flex-wrap: nowrap; }
150
+ .coldwire-archive-title { flex: 1 1 auto; min-width: 0; overflow: hidden;
151
+ text-overflow: ellipsis; white-space: nowrap; font-size: 0.9375rem; font-weight: 600; }
152
+ /* What pressing them got you, under the words rather than under the buttons. */
153
+ .coldwire-archive-meta { margin-top: 0.4rem; font-size: 0.8125rem; }
125
154
  /* Under the filter, because it answers what the filter asks. */
126
155
  .coldwire-count { margin: 0 0 0.6rem; }
127
156
 
@@ -200,5 +229,6 @@
200
229
  .coldwire-pulse { animation: none; }
201
230
  .coldwire-switch span,
202
231
  .coldwire-switch span::after,
232
+ .coldwire-sync > summary::after,
203
233
  .coldwire-inspect-label::after { transition: none; }
204
234
  }
@@ -3,18 +3,79 @@
3
3
  // fetched again until one returns. So a sweep proves the network first, and only then takes
4
4
  // what nothing has asked for in a long time — and, if the cache is still over its ceiling,
5
5
  // whatever has gone longest unread until it fits.
6
+ //
7
+ // Somebody choosing a ceiling on the settings page is the exception, and applyCeiling() below
8
+ // is where it is made.
6
9
  let collecting = null
7
10
 
11
+ // One pass over the cache at a time. Two of them deleting at once would each be measuring a
12
+ // total the other is still taking from, and both would stop early.
13
+ function track(run) {
14
+ collecting = run
15
+ run.catch(() => {}).then(() => { if (collecting === run) collecting = null })
16
+
17
+ return run
18
+ }
19
+
8
20
  // The ceiling is a per-device choice, so it arrives with the request rather than being baked
9
21
  // into the worker. Undefined is a page that has nothing to say about it — an older client, or
10
22
  // one that never loaded the collector — and falls back to what the app configured.
11
23
  function collectGarbage({ maxSize } = {}) {
12
24
  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 })
25
+ // A sweep already running is the answer to this one too: it is doing the same automatic
26
+ // work, and a sweep is cheap to be late.
27
+ return collecting || track(runCollection(limit))
28
+ }
29
+
30
+ // Somebody has just set the ceiling and is watching the number under it. Two things separate
31
+ // this from a sweep:
32
+ //
33
+ // It does not prove the connection first. The probe is there because nothing asked for an
34
+ // automatic sweep, so the cost of getting it wrong falls on somebody who never requested it.
35
+ // This was requested: it is the same deliberate instruction Clear cache is, and that has
36
+ // never waited for a network to agree. Refusing until the connection returns would answer a
37
+ // question nobody asked.
38
+ //
39
+ // It never joins a run already in flight. That run is working to the ceiling this call
40
+ // replaces, so its answer is the answer to the old question — the very thing that made the
41
+ // setting look like it did nothing. It queues behind instead.
42
+ function applyCeiling(maxSize) {
43
+ const queued = collecting ? collecting.catch(() => {}) : Promise.resolve()
44
+
45
+ return track(queued.then(() => runTrim(maxSize)))
46
+ }
47
+
48
+ // Only the ceiling. Age collection stays behind the probe, because nothing has asked for it.
49
+ async function runTrim(maxSize) {
50
+ if (maxSize === undefined || maxSize === null) return { ok: true, evicted: 0, trimmed: false }
16
51
 
17
- return collecting
52
+ const cache = await caches.open(CACHE_NAME)
53
+ const { evicted, bytes } = await trimToSize(cache, await collectable(cache), maxSize)
54
+
55
+ return { ok: true, evicted, bytes, trimmed: true, finishedAt: Date.now() }
56
+ }
57
+
58
+ // Everything a sweep is allowed to take, with the stamp it sorts by.
59
+ async function collectable(cache) {
60
+ const spared = offlinePageAssets()
61
+
62
+ return (await cache.keys())
63
+ .filter((key) => !isSpared(key, spared))
64
+ .map((key) => ({ key, at: unixTimestamp(key.headers.get(TIMESTAMP_HEADER)) }))
65
+ }
66
+
67
+ // The URLs a sweep may never take, for the settings page.
68
+ //
69
+ // The page has to add up the same bytes the worker does or its bar lies: counting files the
70
+ // worker is not allowed to touch shows an overage that no amount of trimming will ever bring
71
+ // down, and the setting reads as broken when it is working exactly as told. The page knows
72
+ // about downloads by their query, but the offline page's own assets it cannot know, because
73
+ // working them out means parsing the offline page — which only the worker holds.
74
+ //
75
+ // The list, rather than the total: the page has already read every entry and its size to draw
76
+ // the list, so this is all it is missing, and one message beats a second pass over the cache.
77
+ async function sparedUrls() {
78
+ return { ok: true, urls: [ ...offlinePageAssets() ] }
18
79
  }
19
80
 
20
81
  async function runCollection(maxSize) {
@@ -75,6 +75,13 @@ self.addEventListener("message", (event) => {
75
75
  // rides along, because it is the device's choice and the worker cannot read localStorage.
76
76
  if (type === "collect") return reply(collectGarbage(event.data))
77
77
 
78
+ // The ceiling, applied the moment somebody picks one. Deliberate, so unlike a sweep it
79
+ // does not stand down without a connection.
80
+ if (type === "trim") return reply(applyCeiling(event.data.maxSize))
81
+
82
+ // What the page must leave out of its own tally to agree with what a sweep can take.
83
+ if (type === "spared") return reply(sparedUrls())
84
+
78
85
  // What a page missed by not listening yet, and whether it is still going on.
79
86
  if (type === "syncState") return reply({ ok: true, running: Boolean(syncing), last: lastSyncMessage })
80
87
 
@@ -46,8 +46,14 @@ async function describeCached(request, response) {
46
46
  async function entrySize(response) {
47
47
  if (!response) return 0
48
48
 
49
- const declared = Number(response.headers.get("Content-Length"))
50
- if (Number.isFinite(declared) && declared >= 0) return declared
49
+ // `get` answers null for a missing header, and Number(null) is 0 — which is finite, and not
50
+ // negative, so it sails through the guard and reports the entry as weighing nothing. Rails
51
+ // sends a great deal of HTML chunked, with no Content-Length at all, so this is not an edge
52
+ // case: it is most pages. A collector measuring them at zero never reaches its ceiling and
53
+ // quietly deletes nothing, reporting success the whole time.
54
+ const declared = response.headers.get("Content-Length")
55
+ const bytes = declared === null ? NaN : Number(declared)
56
+ if (Number.isFinite(bytes) && bytes >= 0) return bytes
51
57
 
52
58
  return (await response.clone().blob()).size
53
59
  }
@@ -3,7 +3,7 @@ function shouldHandle(request) {
3
3
  if (request.method !== "GET") return false
4
4
 
5
5
  const url = new URL(request.url)
6
- if (!cacheableOrigin(url)) return false
6
+ if (!cacheableHost(url)) return false
7
7
 
8
8
  // A Range request cannot be stored as it arrives — cache.put refuses a 206 — so it is
9
9
  // stored as a 200 under a key naming the range, and answered with a 206 built here. Only
@@ -16,8 +16,8 @@ function shouldHandle(request) {
16
16
 
17
17
  // Our own origin, plus any the host app has nominated. A worker sees every request a page
18
18
  // makes, and caching other people's responses uninvited is not its business.
19
- function cacheableOrigin(url) {
20
- return url.origin === self.location.origin || CACHE_ORIGINS.includes(url.origin)
19
+ function cacheableHost(url) {
20
+ return url.origin === self.location.origin || CACHEABLE_HOSTS.includes(url.host)
21
21
  }
22
22
 
23
23
  function matchesPath(url, paths) {
@@ -83,7 +83,7 @@ function isAutoCacheable(request) {
83
83
 
84
84
  // A nominated origin is the opt-in; the path lists describe this app's own surfaces and say
85
85
  // nothing useful about somebody else's.
86
- if (url.origin !== self.location.origin) return CACHE_ORIGINS.includes(url.origin)
86
+ if (url.origin !== self.location.origin) return CACHEABLE_HOSTS.includes(url.host)
87
87
 
88
88
  if (isNeverCached(url)) return false
89
89
 
@@ -43,7 +43,7 @@ function urlsFromHtml(html, pageUrl) {
43
43
  // Any origin we are allowed to cache, not just our own. A page whose map library
44
44
  // comes off a CDN is not offline-ready without it: precaching the page and skipping
45
45
  // the script it cannot run without leaves a blank screen and a full cache.
46
- if (!cacheableOrigin(url)) return
46
+ if (!cacheableHost(url)) return
47
47
  if (matchesPath(url, NEVER_INTERCEPT)) return
48
48
  urls.add(url.href)
49
49
  } catch {}
@@ -54,8 +54,9 @@ Coldwire.configure do |config|
54
54
  # Never intercepted, so these fail outright offline. Coldwire's own routes are added for you.
55
55
  config.never_intercept = [ "/up" ] # probe_path is added for you
56
56
 
57
- # Origins besides your own the worker may cache, and URLs whose Range requests it caches.
58
- config.cache_origins = []
57
+ # The hosts besides your own the worker may cache, and URLs whose Range requests it caches.
58
+ # No scheme — "tiles.example.com" — and a port only where it is not the default.
59
+ config.cacheable_hosts = []
59
60
  config.cache_ranges = []
60
61
 
61
62
  # Large files somebody can download for offline use. Nothing downloads on its own.
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.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Noreaster Group, Stuart Yamartino