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 +4 -4
- data/CHANGELOG.md +18 -11
- data/VERSION +1 -1
- data/app/controllers/coldwire/caches_controller.rb +1 -1
- data/app/views/coldwire/service_worker/show.js.erb +11 -0
- data/docs/configuration.md +30 -0
- data/docs/how-it-works.md +21 -0
- data/lib/coldwire/precache.rb +56 -0
- data/lib/coldwire/worker/collect.js +6 -1
- data/lib/coldwire/worker/inspect.js +99 -6
- 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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7b29177b5dd7843a32faa8d813d3e69cbac5e17c080740944203d0b441fd9fd2
|
|
4
|
+
data.tar.gz: 5a0623dd26cce626b03e899aacba3cf4514423dd204609d7a6be214b619bc78c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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` (
|
|
34
|
-
|
|
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.
|
|
1
|
+
0.5.0
|
|
@@ -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.
|
data/docs/configuration.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|