coldwire-rails 0.1.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +28 -26
- data/VERSION +1 -1
- data/app/assets/javascripts/coldwire/cache_controller.js +116 -4
- data/app/assets/javascripts/coldwire/entries.js +15 -0
- data/app/assets/javascripts/coldwire/format.js +11 -1
- data/app/helpers/coldwire/service_worker_helper.rb +5 -0
- data/app/views/coldwire/caches/show.html.erb +47 -0
- data/app/views/coldwire/service_worker/show.js.erb +10 -1
- data/docs/README.md +3 -2
- data/docs/configuration.md +185 -31
- data/docs/how-it-works.md +1 -1
- data/docs/images/coldwire-logo-dark.svg +57 -0
- data/docs/images/coldwire-logo.svg +57 -0
- data/docs/images/coldwire-mark-dark.svg +26 -0
- data/docs/images/coldwire-mark.svg +26 -0
- data/docs/images/noreaster-group.png +0 -0
- data/docs/images/offline-settings.png +0 -0
- data/docs/setup.md +13 -8
- data/lib/coldwire/client/collect.js +49 -0
- data/lib/coldwire/client/store.js +23 -1
- data/lib/coldwire/configuration.rb +121 -17
- data/lib/coldwire/debug.css +7 -0
- data/lib/coldwire/source.rb +1 -1
- data/lib/coldwire/worker/collect.js +153 -0
- data/lib/coldwire/worker/events.js +5 -0
- data/lib/coldwire/worker/rules.js +4 -4
- data/lib/coldwire/worker/serve.js +7 -4
- data/lib/generators/coldwire/install/install_generator.rb +1 -1
- data/lib/generators/coldwire/install/templates/coldwire.rb +24 -7
- metadata +9 -2
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
// COLDWIRE.cachingEnabledByDefault, so a fresh device gets the app's default.
|
|
17
17
|
caching: "coldwire-caching",
|
|
18
18
|
// The URL list on the settings page. Closed unless they have opened it.
|
|
19
|
-
inspect: "coldwire-inspect"
|
|
19
|
+
inspect: "coldwire-inspect",
|
|
20
|
+
// When the cache was last swept. Only written when a sweep actually ran, so a run
|
|
21
|
+
// skipped for want of a connection leaves the next page still due.
|
|
22
|
+
collectedAt: "coldwire-collected-at",
|
|
23
|
+
// How much this device is willing to give the cache, in bytes, or "none" for no
|
|
24
|
+
// ceiling. Set only from the offline settings page; unset follows COLDWIRE.maxSize,
|
|
25
|
+
// which is what the app configured.
|
|
26
|
+
maxSize: "coldwire-max-size"
|
|
20
27
|
},
|
|
21
28
|
|
|
22
29
|
get: function (key) {
|
|
@@ -57,6 +64,21 @@
|
|
|
57
64
|
this.set(key, value ? "1" : "0")
|
|
58
65
|
},
|
|
59
66
|
|
|
67
|
+
// The ceiling the next sweep works to, in bytes, or null for none. Same shape as the
|
|
68
|
+
// switch above: a choice made on this device beats the app's default, and no choice yet
|
|
69
|
+
// means the default. A stored value that is not a positive number is a storage somebody
|
|
70
|
+
// has edited by hand, and "no ceiling" is not a safe reading of nonsense — fall back.
|
|
71
|
+
maxSize: function () {
|
|
72
|
+
var configured = window.COLDWIRE.maxSize
|
|
73
|
+
var stored = this.get(this.keys.maxSize)
|
|
74
|
+
if (stored === null) return configured === undefined ? null : configured
|
|
75
|
+
if (stored === "none") return null
|
|
76
|
+
|
|
77
|
+
var bytes = Number(stored)
|
|
78
|
+
|
|
79
|
+
return Number.isFinite(bytes) && bytes > 0 ? bytes : configured || null
|
|
80
|
+
},
|
|
81
|
+
|
|
60
82
|
// The Offline support switch: an explicit choice beats the configured default, and no
|
|
61
83
|
// choice yet means the default.
|
|
62
84
|
cachingOn: function () {
|
|
@@ -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
|
-
#
|
|
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
|
-
|
|
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
|
|
97
|
-
@
|
|
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
|
|
@@ -142,6 +148,96 @@ module Coldwire
|
|
|
142
148
|
@auto_sync
|
|
143
149
|
end
|
|
144
150
|
|
|
151
|
+
# Clearing out what has gone unused, so a cache that fills as people browse does not fill
|
|
152
|
+
# forever. Only ever removes; never fetches.
|
|
153
|
+
#
|
|
154
|
+
# config.garbage_collection do |gc|
|
|
155
|
+
# gc.max_age = 60 * 60 * 24 * 60
|
|
156
|
+
# gc.max_size = 500 * 1024 * 1024
|
|
157
|
+
# end
|
|
158
|
+
def garbage_collection
|
|
159
|
+
@garbage_collection ||= GarbageCollection.new
|
|
160
|
+
yield(@garbage_collection) if block_given?
|
|
161
|
+
|
|
162
|
+
@garbage_collection
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Deleting is the one cache operation with no way back: whatever goes is gone until the
|
|
166
|
+
# network can be reached again. So a sweep happens only with a connection confirmed, and
|
|
167
|
+
# only for entries nothing has asked for in a long time — or, once the cache is over its
|
|
168
|
+
# ceiling, for whatever has gone longest unread.
|
|
169
|
+
class GarbageCollection
|
|
170
|
+
# On by default, unlike syncing. A sweep costs no data and takes nothing anybody has
|
|
171
|
+
# used lately — where an unbounded cache costs storage on somebody's phone forever.
|
|
172
|
+
attr_accessor :enabled
|
|
173
|
+
|
|
174
|
+
# How long an entry may go untouched before it is collected. Anything read while a page
|
|
175
|
+
# is being stored is renewed, so this measures disuse rather than age.
|
|
176
|
+
attr_accessor :max_age
|
|
177
|
+
|
|
178
|
+
# How much the cache may hold, in bytes. Past it a sweep takes the least recently used
|
|
179
|
+
# entries first, until what is left fits — so a device that browses a great deal more
|
|
180
|
+
# than it revisits has a ceiling rather than only a deadline. nil is no ceiling.
|
|
181
|
+
#
|
|
182
|
+
# Measured over what a sweep is allowed to take, which is everything but the offline
|
|
183
|
+
# page's own assets and downloaded archives: counting a 300 MB download somebody chose
|
|
184
|
+
# to keep would empty the rest of the cache to make room for it.
|
|
185
|
+
attr_reader :max_size
|
|
186
|
+
|
|
187
|
+
def max_size=(bytes)
|
|
188
|
+
@max_size = validate_size(bytes)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# How long to leave between sweeps. Also how long the cache may sit over `max_size`,
|
|
192
|
+
# since that is when the ceiling is applied.
|
|
193
|
+
attr_accessor :interval
|
|
194
|
+
|
|
195
|
+
# What the offline settings page offers, as bytes. A ladder rather than a text field:
|
|
196
|
+
# somebody adjusting this on a phone is choosing roughly how much of their device to
|
|
197
|
+
# spend, not typing a number. The configured default is always among them, or an app
|
|
198
|
+
# that set 300 MB would have no way back to it once somebody had picked something else.
|
|
199
|
+
def size_choices
|
|
200
|
+
choices = [ 50, 100, 250, 500, 1024, 2048 ].map { |mb| mb * 1024 * 1024 }
|
|
201
|
+
choices << max_size if max_size
|
|
202
|
+
|
|
203
|
+
choices.uniq.sort
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def initialize
|
|
207
|
+
@enabled = true
|
|
208
|
+
@max_age = 60 * 24 * 60 * 60
|
|
209
|
+
@max_size = 250 * 1024 * 1024
|
|
210
|
+
@interval = 24 * 60 * 60
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# An entry read again while a page is stored is renewed rather than refetched — but not
|
|
214
|
+
# on every navigation, or every visit would rewrite every asset the page names. A
|
|
215
|
+
# quarter of the lifetime leaves three quarters of headroom before a collection.
|
|
216
|
+
def renew_after
|
|
217
|
+
return nil unless enabled && max_age
|
|
218
|
+
|
|
219
|
+
(max_age.to_i / 4).clamp(1, max_age.to_i)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
# Bytes, and enough of them to be a cache rather than a rounding error. A megabyte
|
|
225
|
+
# ceiling is almost always a unit mistake — someone reaching for `250.megabytes` and
|
|
226
|
+
# writing `250` — and it would sweep away all but the last page or two visited.
|
|
227
|
+
def validate_size(bytes)
|
|
228
|
+
return nil if bytes.nil?
|
|
229
|
+
|
|
230
|
+
size = bytes.to_i
|
|
231
|
+
unless size >= 1024 * 1024
|
|
232
|
+
raise ArgumentError,
|
|
233
|
+
"Coldwire garbage_collection.max_size is in bytes and has to leave room for a " \
|
|
234
|
+
"page and what it loads, so it cannot be under a megabyte: #{bytes.inspect}"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
size
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
145
241
|
# WebKit has no Background Sync, Periodic Background Sync or Background Fetch, so nothing
|
|
146
242
|
# can wake a worker. What a page load can do is hand work to one, which then runs on
|
|
147
243
|
# without it — so syncing is triggered by an open page and paced, not scheduled.
|
|
@@ -167,8 +263,8 @@ module Coldwire
|
|
|
167
263
|
def initialize
|
|
168
264
|
@enabled = false
|
|
169
265
|
@precache_urls = -> { [] }
|
|
170
|
-
@interval =
|
|
171
|
-
@max_age =
|
|
266
|
+
@interval = 24 * 60 * 60
|
|
267
|
+
@max_age = 30 * 24 * 60 * 60
|
|
172
268
|
@concurrency = 4
|
|
173
269
|
end
|
|
174
270
|
end
|
|
@@ -188,7 +284,7 @@ module Coldwire
|
|
|
188
284
|
@register_if = -> { true }
|
|
189
285
|
@caching_enabled_by_default = true
|
|
190
286
|
@cache_identity = -> { nil }
|
|
191
|
-
@
|
|
287
|
+
@cacheable_hosts = []
|
|
192
288
|
@cache_ranges = []
|
|
193
289
|
@cache_archives = []
|
|
194
290
|
end
|
|
@@ -240,19 +336,27 @@ module Coldwire
|
|
|
240
336
|
|
|
241
337
|
# An origin and nothing more: no path, no trailing slash. Anything else silently fails to
|
|
242
338
|
# match a request's origin, which is the same quiet failure as a malformed path pattern.
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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}"
|
|
250
354
|
end
|
|
251
355
|
|
|
252
|
-
unless
|
|
356
|
+
unless value.match?(HOST)
|
|
253
357
|
raise ArgumentError,
|
|
254
|
-
"Coldwire
|
|
255
|
-
"\"
|
|
358
|
+
"Coldwire cacheable_hosts takes bare hosts like " \
|
|
359
|
+
"\"tiles.example.com\": #{host.inspect}"
|
|
256
360
|
end
|
|
257
361
|
|
|
258
362
|
value
|
data/lib/coldwire/debug.css
CHANGED
|
@@ -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; }
|
data/lib/coldwire/source.rb
CHANGED
|
@@ -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
|
|
|
@@ -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 (!
|
|
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
|
|
20
|
-
return url.origin === self.location.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
|
|
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 (!
|
|
46
|
+
if (!cacheableHost(url)) return
|
|
47
47
|
if (matchesPath(url, NEVER_INTERCEPT)) return
|
|
48
48
|
urls.add(url.href)
|
|
49
49
|
} catch {}
|
|
@@ -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
|
|
116
|
-
//
|
|
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
|
-
|
|
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 "
|
|
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 =
|
|
12
|
-
sync.max_age =
|
|
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
|
-
|
|
19
|
-
|
|
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.
|
|
@@ -38,8 +54,9 @@ Coldwire.configure do |config|
|
|
|
38
54
|
# Never intercepted, so these fail outright offline. Coldwire's own routes are added for you.
|
|
39
55
|
config.never_intercept = [ "/up" ] # probe_path is added for you
|
|
40
56
|
|
|
41
|
-
#
|
|
42
|
-
|
|
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 = []
|
|
43
60
|
config.cache_ranges = []
|
|
44
61
|
|
|
45
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.
|
|
4
|
+
version: 0.3.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
|