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.
@@ -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 request = new Request(href, { credentials: "same-origin" })
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
- await putFresh(cache, cacheKey(request, { managed }), response.clone())
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 cached = await cache.match(request, MATCH_OPTIONS)
106
+ const key = await matchStored(cache, request)
107
+ const cached = key ? await cache.match(key) : undefined
98
108
 
99
- return (await cachedPageResponse(cache, request, cached)) || offlineResponse(request)
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")) return
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
- const urls = urlsFromHtml(await response.text(), request.url)
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
- const [ key ] = await cache.keys(href, MATCH_OPTIONS)
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(cache, request, cached) {
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, so ask the cache for the key that matched.
161
- const [ key ] = await cache.keys(request, MATCH_OPTIONS)
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
 
@@ -48,7 +48,8 @@ async function runSync() {
48
48
  return failure
49
49
  }
50
50
 
51
- const wanted = new Set(manifest.map((value) => cacheUrl(new URL(value, self.location.origin).href)))
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 href of wanted) {
62
- const [ key ] = await cache.keys(new Request(href), MATCH_OPTIONS)
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(href)
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
- if (wanted.has(cacheUrl(key.url))) continue
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
- // Compare URLs the same way they are stored, or every entry looks unlisted the moment a
150
- // query string is involved.
151
- function cacheUrl(href) {
152
- const url = new URL(href)
153
- if (IGNORE_SEARCH) url.search = ""
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 url.href
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 (value) => {
223
+ await runPool(inputUrls, SYNC_CONCURRENCY, async (entry) => {
205
224
  try {
206
- const href = new URL(value, self.location.origin).href
207
- const extra = await fetchWithRetry(cache, href, { managed: true })
208
- cached.add(href)
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(String(value))
212
- pageFailures.push(String(value))
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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "coldwire/version"
4
4
  require "coldwire/configuration"
5
+ require "coldwire/precache"
5
6
  require "coldwire/source"
6
7
  require "coldwire/client_user_agent"
7
8
  require "coldwire/engine"
@@ -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.3.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