coldwire-rails 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -1
- 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 +9 -0
- data/docs/README.md +3 -2
- data/docs/configuration.md +167 -19
- 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 +12 -7
- data/lib/coldwire/client/collect.js +49 -0
- data/lib/coldwire/client/store.js +23 -1
- data/lib/coldwire/configuration.rb +92 -2
- 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/serve.js +6 -3
- data/lib/generators/coldwire/install/install_generator.rb +1 -1
- data/lib/generators/coldwire/install/templates/coldwire.rb +21 -5
- metadata +9 -2
|
@@ -142,6 +142,96 @@ module Coldwire
|
|
|
142
142
|
@auto_sync
|
|
143
143
|
end
|
|
144
144
|
|
|
145
|
+
# Clearing out what has gone unused, so a cache that fills as people browse does not fill
|
|
146
|
+
# forever. Only ever removes; never fetches.
|
|
147
|
+
#
|
|
148
|
+
# config.garbage_collection do |gc|
|
|
149
|
+
# gc.max_age = 60 * 60 * 24 * 60
|
|
150
|
+
# gc.max_size = 500 * 1024 * 1024
|
|
151
|
+
# end
|
|
152
|
+
def garbage_collection
|
|
153
|
+
@garbage_collection ||= GarbageCollection.new
|
|
154
|
+
yield(@garbage_collection) if block_given?
|
|
155
|
+
|
|
156
|
+
@garbage_collection
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Deleting is the one cache operation with no way back: whatever goes is gone until the
|
|
160
|
+
# network can be reached again. So a sweep happens only with a connection confirmed, and
|
|
161
|
+
# only for entries nothing has asked for in a long time — or, once the cache is over its
|
|
162
|
+
# ceiling, for whatever has gone longest unread.
|
|
163
|
+
class GarbageCollection
|
|
164
|
+
# On by default, unlike syncing. A sweep costs no data and takes nothing anybody has
|
|
165
|
+
# used lately — where an unbounded cache costs storage on somebody's phone forever.
|
|
166
|
+
attr_accessor :enabled
|
|
167
|
+
|
|
168
|
+
# How long an entry may go untouched before it is collected. Anything read while a page
|
|
169
|
+
# is being stored is renewed, so this measures disuse rather than age.
|
|
170
|
+
attr_accessor :max_age
|
|
171
|
+
|
|
172
|
+
# How much the cache may hold, in bytes. Past it a sweep takes the least recently used
|
|
173
|
+
# entries first, until what is left fits — so a device that browses a great deal more
|
|
174
|
+
# than it revisits has a ceiling rather than only a deadline. nil is no ceiling.
|
|
175
|
+
#
|
|
176
|
+
# Measured over what a sweep is allowed to take, which is everything but the offline
|
|
177
|
+
# page's own assets and downloaded archives: counting a 300 MB download somebody chose
|
|
178
|
+
# to keep would empty the rest of the cache to make room for it.
|
|
179
|
+
attr_reader :max_size
|
|
180
|
+
|
|
181
|
+
def max_size=(bytes)
|
|
182
|
+
@max_size = validate_size(bytes)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# How long to leave between sweeps. Also how long the cache may sit over `max_size`,
|
|
186
|
+
# since that is when the ceiling is applied.
|
|
187
|
+
attr_accessor :interval
|
|
188
|
+
|
|
189
|
+
# What the offline settings page offers, as bytes. A ladder rather than a text field:
|
|
190
|
+
# somebody adjusting this on a phone is choosing roughly how much of their device to
|
|
191
|
+
# spend, not typing a number. The configured default is always among them, or an app
|
|
192
|
+
# that set 300 MB would have no way back to it once somebody had picked something else.
|
|
193
|
+
def size_choices
|
|
194
|
+
choices = [ 50, 100, 250, 500, 1024, 2048 ].map { |mb| mb * 1024 * 1024 }
|
|
195
|
+
choices << max_size if max_size
|
|
196
|
+
|
|
197
|
+
choices.uniq.sort
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def initialize
|
|
201
|
+
@enabled = true
|
|
202
|
+
@max_age = 60 * 24 * 60 * 60
|
|
203
|
+
@max_size = 250 * 1024 * 1024
|
|
204
|
+
@interval = 24 * 60 * 60
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# An entry read again while a page is stored is renewed rather than refetched — but not
|
|
208
|
+
# on every navigation, or every visit would rewrite every asset the page names. A
|
|
209
|
+
# quarter of the lifetime leaves three quarters of headroom before a collection.
|
|
210
|
+
def renew_after
|
|
211
|
+
return nil unless enabled && max_age
|
|
212
|
+
|
|
213
|
+
(max_age.to_i / 4).clamp(1, max_age.to_i)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Bytes, and enough of them to be a cache rather than a rounding error. A megabyte
|
|
219
|
+
# ceiling is almost always a unit mistake — someone reaching for `250.megabytes` and
|
|
220
|
+
# writing `250` — and it would sweep away all but the last page or two visited.
|
|
221
|
+
def validate_size(bytes)
|
|
222
|
+
return nil if bytes.nil?
|
|
223
|
+
|
|
224
|
+
size = bytes.to_i
|
|
225
|
+
unless size >= 1024 * 1024
|
|
226
|
+
raise ArgumentError,
|
|
227
|
+
"Coldwire garbage_collection.max_size is in bytes and has to leave room for a " \
|
|
228
|
+
"page and what it loads, so it cannot be under a megabyte: #{bytes.inspect}"
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
size
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
145
235
|
# WebKit has no Background Sync, Periodic Background Sync or Background Fetch, so nothing
|
|
146
236
|
# can wake a worker. What a page load can do is hand work to one, which then runs on
|
|
147
237
|
# without it — so syncing is triggered by an open page and paced, not scheduled.
|
|
@@ -167,8 +257,8 @@ module Coldwire
|
|
|
167
257
|
def initialize
|
|
168
258
|
@enabled = false
|
|
169
259
|
@precache_urls = -> { [] }
|
|
170
|
-
@interval =
|
|
171
|
-
@max_age =
|
|
260
|
+
@interval = 24 * 60 * 60
|
|
261
|
+
@max_age = 30 * 24 * 60 * 60
|
|
172
262
|
@concurrency = 4
|
|
173
263
|
end
|
|
174
264
|
end
|
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
|
|
|
@@ -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.
|
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.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Noreaster Group, Stuart Yamartino
|
|
@@ -26,7 +26,7 @@ dependencies:
|
|
|
26
26
|
description: When your Hotwire wires go cold. A Rails engine that serves a Cache API
|
|
27
27
|
service worker, precaches the pages you nominate, and falls back to an offline view
|
|
28
28
|
Turbo will actually render. Built against Hotwire Native's rules, which are stricter
|
|
29
|
-
than a browser's, so the same cache works in a plain Hotwire app and an installed
|
|
29
|
+
than a browser's, so the same cache works in a plain Hotwire web app and an installed
|
|
30
30
|
PWA.
|
|
31
31
|
email:
|
|
32
32
|
- stuart@noreastergroup.com
|
|
@@ -56,6 +56,11 @@ files:
|
|
|
56
56
|
- docs/README.md
|
|
57
57
|
- docs/configuration.md
|
|
58
58
|
- docs/how-it-works.md
|
|
59
|
+
- docs/images/coldwire-logo-dark.svg
|
|
60
|
+
- docs/images/coldwire-logo.svg
|
|
61
|
+
- docs/images/coldwire-mark-dark.svg
|
|
62
|
+
- docs/images/coldwire-mark.svg
|
|
63
|
+
- docs/images/noreaster-group.png
|
|
59
64
|
- docs/images/offline-fallback.png
|
|
60
65
|
- docs/images/offline-settings-cached.png
|
|
61
66
|
- docs/images/offline-settings.png
|
|
@@ -63,6 +68,7 @@ files:
|
|
|
63
68
|
- lib/coldwire-rails.rb
|
|
64
69
|
- lib/coldwire.rb
|
|
65
70
|
- lib/coldwire/client/api.js
|
|
71
|
+
- lib/coldwire/client/collect.js
|
|
66
72
|
- lib/coldwire/client/cookie.js
|
|
67
73
|
- lib/coldwire/client/forced.js
|
|
68
74
|
- lib/coldwire/client/identity.js
|
|
@@ -77,6 +83,7 @@ files:
|
|
|
77
83
|
- lib/coldwire/source.rb
|
|
78
84
|
- lib/coldwire/version.rb
|
|
79
85
|
- lib/coldwire/worker/archives.js
|
|
86
|
+
- lib/coldwire/worker/collect.js
|
|
80
87
|
- lib/coldwire/worker/events.js
|
|
81
88
|
- lib/coldwire/worker/inspect.js
|
|
82
89
|
- lib/coldwire/worker/ranges.js
|