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.
@@ -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
- # 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
@@ -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 = 6 * 60 * 60
171
- @max_age = 7 * 24 * 60 * 60
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
- @cache_origins = []
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
- def validate_origin(origin)
244
- value = origin.to_s
245
-
246
- begin
247
- uri = URI.parse(value)
248
- rescue URI::InvalidURIError
249
- 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}"
250
354
  end
251
355
 
252
- unless uri&.scheme && uri.host && uri.path.to_s.empty? && uri.query.nil?
356
+ unless value.match?(HOST)
253
357
  raise ArgumentError,
254
- "Coldwire cache_origins takes bare origins like " \
255
- "\"https://tiles.example.com\": #{origin.inspect}"
358
+ "Coldwire cacheable_hosts takes bare hosts like " \
359
+ "\"tiles.example.com\": #{host.inspect}"
256
360
  end
257
361
 
258
362
  value
@@ -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
 
@@ -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 {}
@@ -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.
@@ -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
- # Origins besides your own the worker may cache, and URLs whose Range requests it caches.
42
- 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 = []
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.1.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