coldwire-rails 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5e35d2c0930a9eac9b39b725205a467bda9a982b1fb543f127e3f2a0cd3adbe
4
- data.tar.gz: 2bb1ad7cb8ded065ad6820885ca8429c582f0bc688a9c2eb288328e85e02591d
3
+ metadata.gz: 7b29177b5dd7843a32faa8d813d3e69cbac5e17c080740944203d0b441fd9fd2
4
+ data.tar.gz: 5a0623dd26cce626b03e899aacba3cf4514423dd204609d7a6be214b619bc78c
5
5
  SHA512:
6
- metadata.gz: d15283073b65d9100749f0247c5ca4abebcdcfbbe429218499212ce67898b078a8222e6ebc533b8c1051607051e113ecf7ea12bfcc3273c16698859ce6c77e11
7
- data.tar.gz: 9573b8cc389f6519222d606cdb1feb0a6019dc76024902d041f18697bb9d70fc149ddb88ce2c6ffe44da8267d25cbc232fe66758b4b8179c0eebc63fa63986ae
6
+ metadata.gz: c97aaf5882d7203931d463a8f9f0bb825bea50c5b8ddd5871db33411587359d1caf943c51d19f8a7629ac152b5310d466dc9504e078e9e7d47212202dbe796b7
7
+ data.tar.gz: 5be03d9e50e9cb0f85e7ae4a4233a65a249b709f4c9eb994a2c2b699fdb682a8d4350e087d1674be978b6ec22ecbd500c2e3a634b88eda91ddcc9c49168c011b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.5.0]
6
+
7
+ - **A Turbo Frame is cached apart from its page.** A frame navigation used to overwrite the
8
+ page at the same URL, so a later visit got a fragment as a whole document — a blank screen,
9
+ and in Hotwire Native no `window.Turbo`. Frame requests now take their own entry first and
10
+ the page second, the way Turbo does online.
11
+ - **Every `respond_to` format is cached apart from the page.** One URL answering HTML, JSON,
12
+ CSV, or RSS no longer shares a single slot. A path that already names its format
13
+ (`/report.json`, `/app.css`) is left alone. A request asking for data gets data or nothing,
14
+ never the page.
15
+ - **The precache manifest can name a frame or a format.** A listing may be a Hash:
16
+ `{ url: feature_path(f), frame: "map_feature_popup" }` or `{ url: report_path(r), format: :json }`.
17
+ `accept:` takes a media type outright. A bare URL is still the page.
18
+ - **A Turbo Stream is never stored.** It is a list of mutations, not a page, and replaying a
19
+ stale one would apply yesterday's changes to today's DOM.
20
+
5
21
  ## [0.4.0]
6
22
 
7
23
  - Updated the offline page to restructure storage and downloads.
@@ -30,17 +46,8 @@ First release. The API may still change before 1.0.
30
46
  with every option and its default, registers the Stimulus controller, and tags the
31
47
  layout. Safe to run twice.
32
48
  - **Garbage collection.** `config.garbage_collection` sweeps entries nothing has used in
33
- `max_age` (60 days by default) and, once the cache is over `max_size` (250 MB by default),
34
- the least recently read of what is left until it fits so a cache that fills as people
35
- browse does not fill forever, on a device that revisits nothing as much as on one that
36
- revisits everything. The ceiling is offered as a ladder of sizes on the offline settings
37
- page and remembered per device, since how much of a phone to spend is not something an app
38
- can know. It measures only what a sweep may take: downloaded archives are an opt-in spend
39
- of somebody's data plan, so they are neither counted nor evicted. Choosing a size applies it
40
- at once rather than at the next sweep, and that pass does not wait for a connection: it is a
41
- deliberate instruction about somebody's own storage, which is how Clear cache has always
42
- behaved.
43
- On by default, unlike syncing: it spends no data. A sweep runs only with a
49
+ `max_age` (30 days by default), so a cache that fills as people browse does not fill
50
+ forever. On by default, unlike syncing: it spends no data. A sweep runs only with a
44
51
  connection it has confirmed by pinging `probe_path`, because deleting is the one cache
45
52
  operation with no way back. Age is measured from last use, not from when an entry was
46
53
  fetched — storing a page renews everything it names, so the stylesheet every page loads
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.0
1
+ 0.5.0
@@ -12,7 +12,7 @@ module Coldwire
12
12
  def pack
13
13
  response.headers["Cache-Control"] = "no-store, private"
14
14
 
15
- render json: { urls: Array(precache_urls) }
15
+ render json: { urls: Coldwire.precache_entries(precache_urls) }
16
16
  end
17
17
 
18
18
  private
@@ -26,6 +26,17 @@ const CACHE_RANGES = compileRules(<%= raw Coldwire.cache_rules(Coldwire.config.c
26
26
  // A range entry is stored under its own key, with the range in the query and the archive's
27
27
  // total size in a header — the two things needed to rebuild a 206 that was never storable.
28
28
  const RANGE_PARAM = "__coldwire_range"
29
+ // Turbo sends `Turbo-Frame` on a frame navigation, and an app that answers it with just the
30
+ // frame returns something that is not the page at that URL. Keyed apart, the same way, or one
31
+ // would answer for the other: a fragment served to a cold visit is a document with no <html>.
32
+ const FRAME_PARAM = "__coldwire_frame"
33
+ // And the same URL answers a `respond_to` block in as many formats as the app defines. A page
34
+ // is the default and carries no param, so every entry stored before this existed keeps the key
35
+ // it already had; anything else names what it is.
36
+ const FORMAT_PARAM = "__coldwire_format"
37
+ // A stream is a list of changes to make to a page, not a page. Replaying a stale one later
38
+ // would apply yesterday's mutations to today's DOM, so it is never stored at all.
39
+ const STREAM_TYPE = "text/vnd.turbo-stream.html"
29
40
  // Archives offered for download in full, and the size of the pieces they arrive in. 8 MB is
30
41
  // small enough that a dropped connection loses seconds rather than minutes, and large enough
31
42
  // that a 300 MB archive is tens of requests rather than thousands.
@@ -154,6 +154,36 @@ Listing a URL here is an explicit instruction: `cache_as_you_go` does not filter
154
154
 
155
155
  A stored page's stylesheets, scripts, and images are fetched with it, whatever the lists say.
156
156
 
157
+ **Naming a frame or a format.** A URL is one field short of naming a body: the same path
158
+ answers a visit, a Turbo Frame and a `respond_to` format with three different things, and a
159
+ bare listing asks for the page. A Hash says which one you mean:
160
+
161
+ ```ruby
162
+ sync.precache_urls = -> {
163
+ Feature.published.map { |f| { url: feature_path(f), frame: "map_feature_popup" } } +
164
+ Report.all.map { |r| { url: report_path(r), format: :json } } +
165
+ [ root_path ]
166
+ }
167
+ ```
168
+
169
+ `frame:` is the frame tag's own id, which is what Turbo puts in the `Turbo-Frame` header. The
170
+ worker fetches with that header, so an app branching on `turbo_frame_request?` answers with the
171
+ frame, and the body is stored where a frame request will look for it.
172
+
173
+ `format:` is any registered Rails format, including one your app registered itself. It is
174
+ resolved to that format's media type here and sent as `Accept`, so the worker never has to know
175
+ what `:json` means. `accept:` takes a media type directly, for anything with no registered name.
176
+
177
+ Without this, a frame URL is precached as its whole page. That still works offline, since a
178
+ frame request falls back to the page and Turbo pulls the frame out of it, but only while the
179
+ page actually contains that frame, and it stores the whole document where a fragment would do.
180
+
181
+ List a URL twice to precache both:
182
+
183
+ ```ruby
184
+ [ feature_path(f), { url: feature_path(f), frame: "map_feature_popup" } ]
185
+ ```
186
+
157
187
  ### `auto_sync.interval`
158
188
 
159
189
  **Default:** `1.day`
data/docs/how-it-works.md CHANGED
@@ -23,6 +23,27 @@ Turbo — rather than a native error screen:
23
23
  3. **Turbo Frames need a frame.** A frame request discards any response without a matching
24
24
  `<turbo-frame>`, leaving the frame loading forever. Coldwire reads the `Turbo-Frame`
25
25
  header and answers with one.
26
+
27
+ **And a frame is not the page it came from.** Turbo sends that header on every frame
28
+ navigation, and an app answering it with `turbo_frame_request?` returns just the frame.
29
+ Keyed on the URL alone, that body lands in the slot the page occupies: a later cold visit
30
+ is then served a fragment as a whole document, which is a blank screen, and in Hotwire
31
+ Native a page where `window.Turbo` never appears. Whichever was cached last wins, so it
32
+ also happens in reverse. `Vary` cannot fix this, because matching is URL-only by design
33
+ (see 1), so Coldwire puts the frame in the key instead. A frame takes its own entry first
34
+ and the page second, since Turbo pulls a frame out of a document exactly as it does
35
+ online. An ordinary visit never takes the reverse trade.
36
+
37
+ **And neither is any other format.** The same URL answers a `respond_to` block in as many
38
+ formats as the app defines: `/report` is a page to Turbo, JSON to a `fetch`, a CSV to an
39
+ export link and an RSS feed to a reader. Coldwire names the format in the key too, with the
40
+ page left unnamed so anything cached before this keeps the key it had. The name is worked
41
+ out from the request's `Accept`, not the response's type, because the same name has to be
42
+ produced again when the entry is looked for, and there is no response to read then. A
43
+ request asking for data gets data or nothing, since handing it a page is the mistake in 6
44
+ wearing a different hat. A path that names its own format is left alone: `/report.json` is
45
+ JSON and nothing else, so only `/report` needs telling apart. A Turbo Stream is never stored at all: it is a list of changes to
46
+ make to a page, and replaying a stale one applies yesterday's mutations to today's DOM.
26
47
  4. **A followed redirect poisons the cache.** A signed-out request to `/` gets a `302` that
27
48
  `fetch` follows; the result looks fine and `cache.put()` stores it without complaint.
28
49
  Now `/` holds the sign-in page and keeps `redirected: true` — and serving a redirected
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/object/blank"
4
+ require "active_support/core_ext/array/wrap"
5
+
6
+ module Coldwire
7
+ class << self
8
+ # The precache manifest, as the worker needs it. A bare URL is a page; a Hash can also name
9
+ # the Turbo frame it is loaded into, or the format it is fetched as, which are the two
10
+ # things a URL alone cannot say:
11
+ #
12
+ # sync.precache_urls = -> {
13
+ # Feature.published.map { |f| { url: feature_path(f), frame: "map_feature_popup" } } +
14
+ # Report.all.map { |r| { url: report_path(r), format: :json } }
15
+ # }
16
+ #
17
+ # Listing one URL twice is how you ask for both the page and the frame, so nothing here
18
+ # collapses them.
19
+ def precache_entries(list)
20
+ Array.wrap(list).map { |entry| precache_entry(entry) }
21
+ end
22
+
23
+ private
24
+
25
+ def precache_entry(entry)
26
+ return { url: entry.to_s } unless entry.is_a?(Hash)
27
+
28
+ entry = entry.transform_keys(&:to_sym)
29
+ url = entry[:url].to_s
30
+ raise ArgumentError, "Coldwire precache_urls entries need a url: #{entry.inspect}" if url.empty?
31
+
32
+ { url: url, frame: entry[:frame].presence&.to_s, accept: precache_accept(entry) }.compact
33
+ end
34
+
35
+ # A format becomes the Accept header the worker asks with, resolved here rather than in the
36
+ # worker because Rails already knows what every registered format means — including any the
37
+ # app registered itself.
38
+ #
39
+ # `format: :json` is the friendly way to say it and `accept:` is the way out when a media
40
+ # type has no registered name. Naming a format that is not registered is a typo worth
41
+ # refusing: it would otherwise be fetched as HTML and cached as the page.
42
+ def precache_accept(entry)
43
+ return entry[:accept].to_s if entry[:accept].present?
44
+ return nil if entry[:format].blank?
45
+
46
+ mime = Mime[entry[:format]]
47
+ unless mime
48
+ raise ArgumentError,
49
+ "Coldwire precache_urls format #{entry[:format].inspect} is not a registered Mime " \
50
+ "type. Register it with `Mime::Type.register`, or give `accept:` instead."
51
+ end
52
+
53
+ mime.to_s
54
+ end
55
+ end
56
+ end
@@ -210,5 +210,10 @@ async function renew(cache, key) {
210
210
  if (!response) return
211
211
 
212
212
  const managed = key.headers.get(MANAGED_HEADER) === "1"
213
- await putFresh(cache, cacheKey(new Request(key.url, { method: "GET" }), { managed }), response)
213
+ // Put it back where it was. Rebuilt from the URL alone, a frame entry would lose the frame
214
+ // it names and land on its page's key, which is the collision this all exists to avoid.
215
+ const params = new URL(key.url).searchParams
216
+ const frame = params.get(FRAME_PARAM)
217
+ const format = params.get(FORMAT_PARAM)
218
+ await putFresh(cache, cacheKey(new Request(key.url, { method: "GET" }), { managed, frame, format }), response)
214
219
  }
@@ -67,18 +67,111 @@ async function entrySize(response) {
67
67
  // would find the entry either way, but every distinct query string would still write its own
68
68
  // copy — a map that rewrites lat/lng/zoom on each pan would bury the cache in near-duplicates
69
69
  // of one page.
70
- function cacheKey(request, { managed = false } = {}) {
70
+ // `frame` names the Turbo frame this response is a body for, and is what keeps a frame out of
71
+ // the slot its page occupies. Defaults to whatever the request asked for; passed explicitly
72
+ // where the caller knows better — a frame request answered with a whole document is a page,
73
+ // and renewing an entry has to put it back where it already was.
74
+ function cacheKey(request, { managed = false, frame = request.headers.get("Turbo-Frame"), format = formatOf(request) } = {}) {
71
75
  const headers = new Headers(request.headers)
72
76
  headers.set(TIMESTAMP_HEADER, String(Math.floor(Date.now() / 1000)))
73
77
  if (managed) headers.set(MANAGED_HEADER, "1")
74
78
 
75
79
  // `new Request(request, init)` downgrades a navigation request's mode for us; rebuilding
76
- // from a URL string needs the method stated explicitly.
77
- if (!IGNORE_SEARCH) return new Request(request, { headers })
80
+ // from a URL string needs the method stated explicitly. Only available while the URL is
81
+ // being kept as it is, which naming a variant is not: taking this path with a format to
82
+ // write stored a JSON body under the page's own key, which is the whole bug in miniature.
83
+ const named = Boolean(frame) || (format && format !== "page")
84
+ if (!IGNORE_SEARCH && !named) return new Request(request, { headers })
78
85
 
79
- const url = new URL(request.url)
80
- url.search = ""
81
- return new Request(url.href, { method: "GET", headers })
86
+ return new Request(variantUrl(request.url, { frame, format }), { method: "GET", headers })
87
+ }
88
+
89
+ // Where a body of this kind lives. Built in one place because it is written by cacheKey and
90
+ // looked for by matchStored, and a frame stored under a URL nothing asks for is a frame that
91
+ // never answers.
92
+ function variantUrl(url, { frame = null, format = null } = {}) {
93
+ const target = new URL(url)
94
+ if (IGNORE_SEARCH) target.search = ""
95
+ if (frame) target.searchParams.set(FRAME_PARAM, frame)
96
+ // A page is the default and says nothing, which is what leaves every entry stored before
97
+ // formats existed exactly where it was.
98
+ if (format && format !== "page") target.searchParams.set(FORMAT_PARAM, format)
99
+
100
+ return target.href
101
+ }
102
+
103
+ // The format a request negotiated for, as a short token.
104
+ //
105
+ // Read from the request rather than from the response, which is the opposite of what it should
106
+ // be and is forced: the same derivation has to run when the entry is looked for again, and at
107
+ // that moment there is no response to read. With ignore_query_params off a named key can only
108
+ // be found by building its name, so a name the request cannot produce is a name nothing ever
109
+ // finds.
110
+ //
111
+ // A page is the default and is left unnamed, which is what leaves every entry cached before
112
+ // formats existed exactly where it was.
113
+ function formatOf(request) {
114
+ const token = negotiatedFormat(request)
115
+ if (token === "page") return "page"
116
+
117
+ // A URL that already names its format has nothing to disambiguate. "/report.json" is JSON and
118
+ // nothing else, where "/report" is a page, a JSON body and a CSV depending on who asks, so the
119
+ // param there would be a second way of saying what the path already said.
120
+ //
121
+ // Only where the extension agrees with what was asked for. "/sites/acme.com" is a page whose
122
+ // last segment happens to contain a dot, and ".com" says nothing about a format — so that one
123
+ // keeps its param and stays apart from the JSON at the same URL.
124
+ return extensionFormat(request.url) === token ? "page" : token
125
+ }
126
+
127
+ // What the path itself declares, if anything. Unknown extensions say nothing and keep their
128
+ // param, which is noisier than it needs to be and never wrong.
129
+ const EXTENSION_FORMATS = {
130
+ json: "json", geojson: "json", css: "css", xml: "xml", rss: "xml", atom: "xml",
131
+ csv: "csv", ics: "calendar", pdf: "pdf", txt: "plain", md: "markdown",
132
+ png: "image", jpg: "image", jpeg: "image", gif: "image", webp: "image", avif: "image",
133
+ svg: "image", ico: "image"
134
+ }
135
+
136
+ function extensionFormat(url) {
137
+ const name = new URL(url).pathname.split("/").pop() || ""
138
+ const dot = name.lastIndexOf(".")
139
+ if (dot < 1) return "page"
140
+
141
+ return EXTENSION_FORMATS[name.slice(dot + 1).toLowerCase()] || "page"
142
+ }
143
+
144
+ function negotiatedFormat(request) {
145
+ const accept = (request.headers.get("Accept") || "").split(",")[0].split(";")[0].trim().toLowerCase()
146
+
147
+ // Nothing definite asked for. A precache, a fetch that set no Accept, a browser asking for a
148
+ // script or a font: all of them get the unnamed key, as they always have.
149
+ if (!accept || accept === "*/*" || accept.includes("html")) return "page"
150
+
151
+ const [ top, sub = "" ] = accept.split("/")
152
+ // image/avif and image/webp are one question asked two ways, and which one a browser puts
153
+ // first is not a distinction worth a second copy. The family is the answer.
154
+ if (top && top !== "text" && top !== "application") return top
155
+
156
+ // "application/vnd.api+json" is JSON. "text/csv" is csv.
157
+ const parts = sub.split("+")
158
+ const token = (parts.length > 1 ? parts[parts.length - 1] : parts[0]) || ""
159
+
160
+ return token.replace(/[^a-z0-9.-]/g, "") || "page"
161
+ }
162
+
163
+ // What kind of body an entry holds, read back off its key. Documents have none of these
164
+ // params and answer to null, which is what an ordinary visit asks for.
165
+ function variantOf(key) {
166
+ const params = new URL(key.url).searchParams
167
+ // Downloads are their own thing entirely and must never answer a page request, which
168
+ // ignoreSearch would otherwise let them do.
169
+ if (params.has(CHUNK_PARAM) || params.has(RANGE_PARAM)) return "download"
170
+
171
+ const frame = params.get(FRAME_PARAM)
172
+ if (frame) return `frame:${frame}`
173
+
174
+ return params.get(FORMAT_PARAM) || "page"
82
175
  }
83
176
 
84
177
  function unixTimestamp(value) {
@@ -96,5 +96,9 @@ function isAutoCacheable(request) {
96
96
  // network error by spec, so the app fails to launch offline rather than showing the
97
97
  // cached page. Never store one.
98
98
  function isCacheable(request, response) {
99
- return request.method === "GET" && response.ok && !response.redirected
99
+ if (request.method !== "GET" || !response.ok || response.redirected) return false
100
+
101
+ // A stream is a list of changes to make to a page, not a page. Stored, it would sit in the
102
+ // slot the page occupies and be replayed later against a DOM it was never written for.
103
+ return !(response.headers.get("Content-Type") || "").includes(STREAM_TYPE)
100
104
  }
@@ -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.4.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