coldwire-rails 0.3.0 → 0.5.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 +23 -8
- data/README.md +1 -1
- data/VERSION +1 -1
- data/app/assets/javascripts/coldwire/cache_controller.js +78 -46
- data/app/controllers/coldwire/caches_controller.rb +1 -1
- data/app/views/coldwire/caches/show.html.erb +129 -80
- data/app/views/coldwire/service_worker/show.js.erb +11 -0
- data/docs/configuration.md +62 -10
- data/docs/how-it-works.md +21 -0
- data/docs/setup.md +5 -3
- data/lib/coldwire/debug.css +37 -7
- data/lib/coldwire/precache.rb +56 -0
- data/lib/coldwire/worker/collect.js +71 -5
- data/lib/coldwire/worker/events.js +7 -0
- data/lib/coldwire/worker/inspect.js +107 -8
- data/lib/coldwire/worker/rules.js +5 -1
- data/lib/coldwire/worker/serve.js +132 -13
- data/lib/coldwire/worker/sync.js +37 -17
- data/lib/coldwire.rb +1 -0
- data/lib/generators/coldwire/install/templates/coldwire.rb +2 -0
- metadata +2 -1
|
@@ -9,8 +9,14 @@ async function putFresh(cache, key, response) {
|
|
|
9
9
|
await cache.put(key, response)
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
async function fetchAndCache(cache, href, { managed = false } = {}) {
|
|
13
|
-
const
|
|
12
|
+
async function fetchAndCache(cache, href, { managed = false, frame = null, accept = null } = {}) {
|
|
13
|
+
const headers = {}
|
|
14
|
+
// A manifest listing that names a frame or a format is asking for the body a request like
|
|
15
|
+
// that would get, so the request has to look like one.
|
|
16
|
+
if (frame) headers["Turbo-Frame"] = frame
|
|
17
|
+
if (accept) headers.Accept = accept
|
|
18
|
+
|
|
19
|
+
const request = new Request(href, { credentials: "same-origin", headers })
|
|
14
20
|
// Skipped rather than failed: the app asked for this URL never to be stored, and a manifest
|
|
15
21
|
// that also names it is a contradiction to resolve quietly in favour of not storing.
|
|
16
22
|
if (isNeverCached(new URL(request.url))) return []
|
|
@@ -20,7 +26,10 @@ async function fetchAndCache(cache, href, { managed = false } = {}) {
|
|
|
20
26
|
throw new Error(response.redirected ? `Redirected to ${response.url}` : `HTTP ${response.status}`)
|
|
21
27
|
}
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
const key = managed
|
|
30
|
+
? cacheKey(request, { managed, frame })
|
|
31
|
+
: await bodyKey(cache, request, response)
|
|
32
|
+
await putFresh(cache, key, response.clone())
|
|
24
33
|
|
|
25
34
|
const contentType = response.headers.get("Content-Type") || ""
|
|
26
35
|
if (!contentType.includes("text/html")) return []
|
|
@@ -94,21 +103,124 @@ async function handleFetch(request, event) {
|
|
|
94
103
|
}
|
|
95
104
|
|
|
96
105
|
async function offlineFallback(cache, request) {
|
|
97
|
-
const
|
|
106
|
+
const key = await matchStored(cache, request)
|
|
107
|
+
const cached = key ? await cache.match(key) : undefined
|
|
98
108
|
|
|
99
|
-
return (await cachedPageResponse(
|
|
109
|
+
return (await cachedPageResponse(request, key, cached)) || offlineResponse(request)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Which stored entry answers this request. One URL can hold the page and a frame of the same
|
|
113
|
+
// name, so this asks for the kind of body the request wants rather than taking whatever the
|
|
114
|
+
// URL turns up — and with ignoreSearch on, what it turns up might be a frame, a byte range, or
|
|
115
|
+
// a chunk of an archive.
|
|
116
|
+
//
|
|
117
|
+
// A frame takes its own entry first and a whole page second: an app that answers frame
|
|
118
|
+
// requests with the full document stores one of those, and Turbo pulls the frame out of it
|
|
119
|
+
// exactly as it does online. The trade never runs the other way. A fragment served to an
|
|
120
|
+
// ordinary visit is a document with no <html>, which is a blank screen, and in Hotwire Native
|
|
121
|
+
// a page where window.Turbo never appears.
|
|
122
|
+
async function matchStored(cache, request) {
|
|
123
|
+
const frame = request.headers.get("Turbo-Frame")
|
|
124
|
+
|
|
125
|
+
if (frame) {
|
|
126
|
+
// Asked for by name rather than found by scanning. With ignore_query_params off the search
|
|
127
|
+
// has to match exactly, and a request carrying no query would never turn up the key that
|
|
128
|
+
// carries the frame in its own.
|
|
129
|
+
const [ own ] = await cache.keys(variantUrl(request.url, { frame }), { ignoreVary: true })
|
|
130
|
+
if (own) return own
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Asked for by name, for the same reason the frame is.
|
|
134
|
+
const format = formatOf(request)
|
|
135
|
+
if (format !== "page") {
|
|
136
|
+
const [ named ] = await cache.keys(variantUrl(request.url, { format }), { ignoreVary: true })
|
|
137
|
+
if (named) return named
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const keys = await cache.keys(request, MATCH_OPTIONS)
|
|
141
|
+
const page = keys.find((candidate) => variantOf(candidate) === "page")
|
|
142
|
+
if (!page) return undefined
|
|
143
|
+
|
|
144
|
+
// The unnamed entry is the right answer for anything cached before formats were named, and
|
|
145
|
+
// for every asset a precache stored without an Accept to go on. It is the wrong answer for a
|
|
146
|
+
// fetch that asked for data and would be handed a page, which is the mistake that gives a
|
|
147
|
+
// stylesheet an HTML body. So ask what it holds before handing it over.
|
|
148
|
+
if (format === "page") return page
|
|
149
|
+
|
|
150
|
+
const held = await cache.match(page)
|
|
151
|
+
|
|
152
|
+
return (held && !(held.headers.get("Content-Type") || "").includes("text/html")) ? page : undefined
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Where a body that is not a page goes: named by the format the request asked for, unless this
|
|
156
|
+
// URL already holds something that is not a page.
|
|
157
|
+
//
|
|
158
|
+
// That exception is the whole reason this is a function. A precache carries no Accept and lands
|
|
159
|
+
// on the unnamed key; the browser then asks for the same stylesheet as `text/css` and would
|
|
160
|
+
// write a second copy beside it. Two of every stylesheet and every image is not a rounding
|
|
161
|
+
// error on a phone.
|
|
162
|
+
//
|
|
163
|
+
// The lookup is only paid where a name would be written at all, so `*/*` requests — scripts,
|
|
164
|
+
// fonts, and precaching itself — cost nothing extra.
|
|
165
|
+
async function bodyKey(cache, request, response) {
|
|
166
|
+
if (formatOf(request) !== "page") {
|
|
167
|
+
const [ plain ] = await cache.keys(variantUrl(request.url, {}), { ignoreVary: true })
|
|
168
|
+
const held = plain ? await cache.match(plain) : null
|
|
169
|
+
if (held && !(held.headers.get("Content-Type") || "").includes("text/html")) {
|
|
170
|
+
return cacheKey(request, { frame: null, format: "page" })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return cacheKey(request, { frame: null })
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// A URL that names its own format is the same key from every direction, so there is nothing
|
|
177
|
+
// another request could have named differently and nothing to look for.
|
|
178
|
+
if (extensionFormat(request.url) !== "page") return cacheKey(request, { frame: null })
|
|
179
|
+
|
|
180
|
+
// And the other way round: this request named nothing, but the browser may already have
|
|
181
|
+
// stored the same file under the format it asked for. Work that name out from what came
|
|
182
|
+
// back, rather than writing a second copy beside it.
|
|
183
|
+
const guess = formatFromType(response)
|
|
184
|
+
if (guess !== "page") {
|
|
185
|
+
const [ named ] = await cache.keys(variantUrl(request.url, { format: guess }), { ignoreVary: true })
|
|
186
|
+
if (named) return cacheKey(request, { frame: null, format: guess })
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return cacheKey(request, { frame: null })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// What a browser's Accept would have produced for a body like this. Only the two families a
|
|
193
|
+
// browser asks for by type: a script or a font arrives on `*/*` and is unnamed on both sides,
|
|
194
|
+
// so neither pays for this lookup.
|
|
195
|
+
function formatFromType(response) {
|
|
196
|
+
const type = (response.headers.get("Content-Type") || "").split(";")[0].trim().toLowerCase()
|
|
197
|
+
if (type.startsWith("text/css")) return "css"
|
|
198
|
+
if (type.startsWith("image/")) return "image"
|
|
199
|
+
|
|
200
|
+
return "page"
|
|
100
201
|
}
|
|
101
202
|
|
|
102
203
|
// A page is stored with whatever it asks for. The lists say which pages are worth keeping as
|
|
103
204
|
// you browse; what one needs in order to render is not a second question, and a page held
|
|
104
205
|
// without its stylesheet is the offline equivalent of not holding it at all.
|
|
105
206
|
async function storeResponse(cache, request, response) {
|
|
106
|
-
await putFresh(cache, cacheKey(request), response.clone())
|
|
107
|
-
|
|
108
207
|
const type = response.headers.get("Content-Type") || ""
|
|
109
|
-
if (!type.includes("text/html"))
|
|
208
|
+
if (!type.includes("text/html")) {
|
|
209
|
+
await putFresh(cache, await bodyKey(cache, request, response), response.clone())
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Read before writing, because what this is decides where it goes. Cloning twice is free:
|
|
214
|
+
// each clone tees the stream, so consuming one leaves the other whole.
|
|
215
|
+
const body = await response.clone().text()
|
|
216
|
+
// A frame request the app answered with the entire document is a page like any other, and
|
|
217
|
+
// Turbo will find the frame inside it. Storing that under a frame key would hold the same
|
|
218
|
+
// bytes twice and leave the next ordinary visit unable to find them.
|
|
219
|
+
const frame = /<html\b/i.test(body) ? null : request.headers.get("Turbo-Frame")
|
|
110
220
|
|
|
111
|
-
|
|
221
|
+
await putFresh(cache, cacheKey(request, { frame }), response.clone())
|
|
222
|
+
|
|
223
|
+
const urls = urlsFromHtml(body, request.url)
|
|
112
224
|
await Promise.all(urls.map((href) => storeSubresource(cache, href)))
|
|
113
225
|
}
|
|
114
226
|
|
|
@@ -118,7 +230,14 @@ async function storeResponse(cache, request, response) {
|
|
|
118
230
|
async function storeSubresource(cache, href) {
|
|
119
231
|
if (isNeverCached(new URL(href))) return
|
|
120
232
|
|
|
121
|
-
|
|
233
|
+
// Any body for this URL that is not a frame of it and not a piece of a download. Asking only
|
|
234
|
+
// for the unnamed one would refetch a stylesheet the browser has already stored under its
|
|
235
|
+
// own format, and land a second copy of it here.
|
|
236
|
+
const key = (await cache.keys(href, MATCH_OPTIONS)).find((held) => {
|
|
237
|
+
const variant = variantOf(held)
|
|
238
|
+
|
|
239
|
+
return variant !== "download" && !variant.startsWith("frame:")
|
|
240
|
+
})
|
|
122
241
|
if (key) return renew(cache, key)
|
|
123
242
|
|
|
124
243
|
try {
|
|
@@ -145,7 +264,7 @@ function untrack(html) {
|
|
|
145
264
|
// Two markers, read at different moments. The <html> attributes are for the first paint of a
|
|
146
265
|
// cold boot, before any JS runs. The <meta> is for Turbo visits, which merge the head but
|
|
147
266
|
// never copy <html> attributes.
|
|
148
|
-
async function cachedPageResponse(
|
|
267
|
+
async function cachedPageResponse(request, key, cached) {
|
|
149
268
|
if (!cached || !wantsHtml(request)) return cached
|
|
150
269
|
|
|
151
270
|
const type = cached.headers.get("Content-Type") || ""
|
|
@@ -157,8 +276,8 @@ async function cachedPageResponse(cache, request, cached) {
|
|
|
157
276
|
let body = untrack(html)
|
|
158
277
|
|
|
159
278
|
if (MARK_CACHED_PAGES) {
|
|
160
|
-
// The timestamp rides on the stored key,
|
|
161
|
-
|
|
279
|
+
// The timestamp rides on the stored key, which the caller has already had to find: asking
|
|
280
|
+
// the cache again could pick a different entry for the same URL than the one being served.
|
|
162
281
|
const cachedAt = key ? key.headers.get(TIMESTAMP_HEADER) : null
|
|
163
282
|
const stamp = cachedAt ? ` ${CACHED_AT_ATTRIBUTE}="${escapeHtml(cachedAt)}"` : ""
|
|
164
283
|
|
data/lib/coldwire/worker/sync.js
CHANGED
|
@@ -48,7 +48,8 @@ async function runSync() {
|
|
|
48
48
|
return failure
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const
|
|
51
|
+
const entries = manifest.map(manifestEntry)
|
|
52
|
+
const wanted = new Set(entries.map((entry) => entry.key))
|
|
52
53
|
|
|
53
54
|
// Retire what the manifest dropped — an unpublished site, say. Only entries the manifest
|
|
54
55
|
// owns, so assets and ordinary browsing are left alone.
|
|
@@ -58,11 +59,13 @@ async function runSync() {
|
|
|
58
59
|
// already good and costs nothing.
|
|
59
60
|
const now = Date.now() / 1000
|
|
60
61
|
const pending = []
|
|
61
|
-
for (const
|
|
62
|
-
|
|
62
|
+
for (const entry of entries) {
|
|
63
|
+
// Asked for by the exact key this entry will be stored under. A page and a frame of it are
|
|
64
|
+
// two listings now, and either one being held says nothing about the other.
|
|
65
|
+
const [ key ] = await cache.keys(entry.key, { ignoreVary: true })
|
|
63
66
|
const at = key ? unixTimestamp(key.headers.get(TIMESTAMP_HEADER)) : null
|
|
64
67
|
|
|
65
|
-
if (!key || !at || (REFETCH_AFTER !== null && now - at > REFETCH_AFTER)) pending.push(
|
|
68
|
+
if (!key || !at || (REFETCH_AFTER !== null && now - at > REFETCH_AFTER)) pending.push(entry)
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
// Every open page hears about this, not just the one that asked. A sync outlives the page
|
|
@@ -137,7 +140,10 @@ async function retireUnlisted(cache, wanted) {
|
|
|
137
140
|
|
|
138
141
|
for (const key of keys) {
|
|
139
142
|
if (key.headers.get(MANAGED_HEADER) !== "1") continue
|
|
140
|
-
|
|
143
|
+
// Compared whole, params and all. Stripping the search here would take the frame or the
|
|
144
|
+
// format off a managed entry, leave it looking like a page nobody listed, and retire it on
|
|
145
|
+
// the first sync after it was fetched.
|
|
146
|
+
if (wanted.has(key.url)) continue
|
|
141
147
|
|
|
142
148
|
await cache.delete(key)
|
|
143
149
|
retired++
|
|
@@ -146,13 +152,26 @@ async function retireUnlisted(cache, wanted) {
|
|
|
146
152
|
return retired
|
|
147
153
|
}
|
|
148
154
|
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
// One manifest listing, worked out once: the request that fetches it, and the key it will be
|
|
156
|
+
// stored under. A bare URL is a page, as it always was; a listing that names a frame or an
|
|
157
|
+
// Accept is asking for the body a request like that would get, and has to be stored where a
|
|
158
|
+
// request like that will look for it.
|
|
159
|
+
function manifestEntry(value) {
|
|
160
|
+
const listing = typeof value === "string" ? { url: value } : (value || {})
|
|
161
|
+
const href = new URL(listing.url, self.location.origin).href
|
|
162
|
+
const headers = {}
|
|
163
|
+
|
|
164
|
+
if (listing.frame) headers["Turbo-Frame"] = listing.frame
|
|
165
|
+
// What Turbo sends alongside the frame header. Without it the manifest asks with `*/*`, and
|
|
166
|
+
// a `respond_to` block answers with whatever it happens to list first.
|
|
167
|
+
if (listing.accept || listing.frame) {
|
|
168
|
+
headers.Accept = listing.accept || "text/html, application/xhtml+xml"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const request = new Request(href, { credentials: "same-origin", headers })
|
|
172
|
+
const key = variantUrl(href, { frame: listing.frame, format: formatOf(request) })
|
|
154
173
|
|
|
155
|
-
return
|
|
174
|
+
return { href, frame: listing.frame, accept: headers.Accept, key, request }
|
|
156
175
|
}
|
|
157
176
|
|
|
158
177
|
// A fixed number of lanes pulling from one queue.
|
|
@@ -201,15 +220,16 @@ async function precacheUrls(inputUrls, onProgress = () => {}) {
|
|
|
201
220
|
let done = 0
|
|
202
221
|
onProgress({ phase: "pages", done, total: inputUrls.length })
|
|
203
222
|
|
|
204
|
-
await runPool(inputUrls, SYNC_CONCURRENCY, async (
|
|
223
|
+
await runPool(inputUrls, SYNC_CONCURRENCY, async (entry) => {
|
|
205
224
|
try {
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
225
|
+
const extra = await fetchWithRetry(cache, entry.href, {
|
|
226
|
+
managed: true, frame: entry.frame, accept: entry.accept
|
|
227
|
+
})
|
|
228
|
+
cached.add(entry.href)
|
|
209
229
|
extra.forEach((url) => assets.add(url))
|
|
210
230
|
} catch {
|
|
211
|
-
failed.push(
|
|
212
|
-
pageFailures.push(
|
|
231
|
+
failed.push(entry.href)
|
|
232
|
+
pageFailures.push(entry.href)
|
|
213
233
|
}
|
|
214
234
|
onProgress({ phase: "pages", done: ++done, total: inputUrls.length })
|
|
215
235
|
})
|
data/lib/coldwire.rb
CHANGED
|
@@ -8,6 +8,8 @@ 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
|
+
# A Hash names a Turbo Frame or a format, for URLs that answer more than one way:
|
|
12
|
+
# sync.precache_urls = -> { Article.published.map { |a| { url: article_path(a), frame: "preview" } } }
|
|
11
13
|
sync.interval = 1.day
|
|
12
14
|
sync.max_age = 30.days
|
|
13
15
|
sync.concurrency = 4
|
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Noreaster Group, Stuart Yamartino
|
|
@@ -80,6 +80,7 @@ files:
|
|
|
80
80
|
- lib/coldwire/configuration.rb
|
|
81
81
|
- lib/coldwire/debug.css
|
|
82
82
|
- lib/coldwire/engine.rb
|
|
83
|
+
- lib/coldwire/precache.rb
|
|
83
84
|
- lib/coldwire/source.rb
|
|
84
85
|
- lib/coldwire/version.rb
|
|
85
86
|
- lib/coldwire/worker/archives.js
|