coldwire-rails 0.1.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.
Files changed (54) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +53 -0
  3. data/LICENSE +21 -0
  4. data/README.md +69 -0
  5. data/VERSION +1 -0
  6. data/app/assets/javascripts/coldwire/archives.js +52 -0
  7. data/app/assets/javascripts/coldwire/cache_controller.js +1058 -0
  8. data/app/assets/javascripts/coldwire/entries.js +55 -0
  9. data/app/assets/javascripts/coldwire/format.js +60 -0
  10. data/app/assets/javascripts/coldwire/worker.js +35 -0
  11. data/app/controllers/coldwire/application_controller.rb +21 -0
  12. data/app/controllers/coldwire/caches_controller.rb +33 -0
  13. data/app/controllers/coldwire/service_worker_controller.rb +26 -0
  14. data/app/helpers/coldwire/service_worker_helper.rb +59 -0
  15. data/app/views/coldwire/caches/show.html.erb +304 -0
  16. data/app/views/coldwire/service_worker/offline_frame.html.erb +16 -0
  17. data/app/views/coldwire/service_worker/offline_page.html.erb +112 -0
  18. data/app/views/coldwire/service_worker/show.js.erb +66 -0
  19. data/config/importmap.rb +7 -0
  20. data/config/routes.rb +8 -0
  21. data/docs/README.md +19 -0
  22. data/docs/configuration.md +539 -0
  23. data/docs/how-it-works.md +64 -0
  24. data/docs/images/offline-fallback.png +0 -0
  25. data/docs/images/offline-settings-cached.png +0 -0
  26. data/docs/images/offline-settings.png +0 -0
  27. data/docs/setup.md +218 -0
  28. data/lib/coldwire/client/api.js +44 -0
  29. data/lib/coldwire/client/cookie.js +13 -0
  30. data/lib/coldwire/client/forced.js +17 -0
  31. data/lib/coldwire/client/identity.js +28 -0
  32. data/lib/coldwire/client/marker.js +35 -0
  33. data/lib/coldwire/client/register.js +19 -0
  34. data/lib/coldwire/client/store.js +69 -0
  35. data/lib/coldwire/client/sync.js +122 -0
  36. data/lib/coldwire/client_user_agent.rb +48 -0
  37. data/lib/coldwire/configuration.rb +319 -0
  38. data/lib/coldwire/debug.css +197 -0
  39. data/lib/coldwire/engine.rb +34 -0
  40. data/lib/coldwire/source.rb +50 -0
  41. data/lib/coldwire/version.rb +11 -0
  42. data/lib/coldwire/worker/archives.js +158 -0
  43. data/lib/coldwire/worker/events.js +79 -0
  44. data/lib/coldwire/worker/inspect.js +81 -0
  45. data/lib/coldwire/worker/ranges.js +136 -0
  46. data/lib/coldwire/worker/rules.js +100 -0
  47. data/lib/coldwire/worker/serve.js +226 -0
  48. data/lib/coldwire/worker/sync.js +234 -0
  49. data/lib/coldwire-rails.rb +5 -0
  50. data/lib/coldwire.rb +55 -0
  51. data/lib/generators/coldwire/install/install_generator.rb +110 -0
  52. data/lib/generators/coldwire/install/templates/coldwire.rb +53 -0
  53. data/lib/tasks/coldwire.rake +8 -0
  54. metadata +113 -0
@@ -0,0 +1,158 @@
1
+ // The facts about a chunk go on the key, not only on the response. cache.keys() hands back
2
+ // Requests, so anything read while counting up what is stored has to live there — the same
3
+ // reason the timestamp does. Matching ignores headers, so a lookup built without them still
4
+ // finds the entry.
5
+ function chunkKey(url, index, facts = null) {
6
+ const target = new URL(url)
7
+ target.searchParams.set(CHUNK_PARAM, String(index))
8
+ if (!facts) return new Request(target.href, { method: "GET" })
9
+
10
+ const headers = new Headers()
11
+ headers.set("coldwire-archive-total", String(facts.total))
12
+ headers.set("coldwire-chunk-size", String(facts.size))
13
+ headers.set(TIMESTAMP_HEADER, String(Math.floor(Date.now() / 1000)))
14
+
15
+ return new Request(target.href, { method: "GET", headers })
16
+ }
17
+
18
+ function chunkCount(total) {
19
+ return Math.ceil(total / ARCHIVE_CHUNK)
20
+ }
21
+
22
+ // How big the thing is, asked for in the cheapest way there is: one byte, and read the total
23
+ // off the Content-Range that comes back.
24
+ async function archiveTotal(url) {
25
+ const response = await fetch(url, { headers: { Range: "bytes=0-0" } })
26
+ if (response.status !== 206) throw new Error(`HTTP ${response.status}`)
27
+
28
+ const total = rangeTotal(response.headers.get("Content-Range"))
29
+ if (!total) throw new Error("No Content-Range")
30
+
31
+ return total
32
+ }
33
+
34
+ // Counted from what is actually stored rather than from a note written when the download ran.
35
+ // A cache can be evicted piecemeal, and a status that disagrees with the cache is worse than
36
+ // no status at all.
37
+ async function archiveStatus(url) {
38
+ if (!CACHE_ARCHIVES.includes(url)) return { ok: false, error: "Not a listed archive" }
39
+
40
+ const cache = await caches.open(CACHE_NAME)
41
+ const stored = await cache.keys()
42
+ const prefix = new URL(url)
43
+ prefix.search = ""
44
+
45
+ let bytes = 0
46
+ let chunks = 0
47
+ let total = null
48
+ // The newest chunk: when this last got any of itself, which for a finished download is
49
+ // when it finished. Chunks already held are skipped on a later pass, so an older one
50
+ // would date the archive from an attempt that may have stopped in the first megabyte.
51
+ let cachedAt = null
52
+
53
+ for (const key of stored) {
54
+ const keyUrl = new URL(key.url)
55
+ keyUrl.search = ""
56
+ if (keyUrl.href !== prefix.href) continue
57
+ if (!new URL(key.url).searchParams.has(CHUNK_PARAM)) continue
58
+
59
+ chunks += 1
60
+ const size = Number(key.headers.get("coldwire-chunk-size"))
61
+ if (Number.isFinite(size)) bytes += size
62
+ const declared = Number(key.headers.get("coldwire-archive-total"))
63
+ if (Number.isFinite(declared) && declared > 0) total = declared
64
+ const stamp = unixTimestamp(key.headers.get(TIMESTAMP_HEADER))
65
+ if (stamp && (!cachedAt || stamp > cachedAt)) cachedAt = stamp
66
+ }
67
+
68
+ return {
69
+ ok: true,
70
+ url,
71
+ chunks,
72
+ bytes,
73
+ total,
74
+ cachedAt,
75
+ expected: total ? chunkCount(total) : null,
76
+ complete: Boolean(total) && chunks === chunkCount(total)
77
+ }
78
+ }
79
+
80
+ // Resumable by construction: every chunk already stored is skipped, so an interrupted download
81
+ // picks up where it stopped rather than starting again. Sequential on purpose — this is a
82
+ // large download over somebody's connection, and running it in parallel lanes would take the
83
+ // bandwidth the app itself is using.
84
+ async function downloadArchive(url) {
85
+ if (!CACHE_ARCHIVES.includes(url)) return { ok: false, error: "Not a listed archive" }
86
+ if (forcedOffline) return { ok: false, offline: true, reason: "forced" }
87
+
88
+ const cache = await caches.open(CACHE_NAME)
89
+
90
+ let total
91
+ try {
92
+ total = await archiveTotal(url)
93
+ } catch (error) {
94
+ return { ok: false, offline: true, error: error.message }
95
+ }
96
+
97
+ const count = chunkCount(total)
98
+ let stored = 0
99
+
100
+ for (let index = 0; index < count; index++) {
101
+ const key = chunkKey(url, index)
102
+
103
+ if (await cache.match(key)) {
104
+ stored += 1
105
+ notifyClients({ type: ARCHIVE_MESSAGE, url, state: "progress", done: stored, total: count })
106
+ continue
107
+ }
108
+
109
+ const start = index * ARCHIVE_CHUNK
110
+ const end = Math.min(start + ARCHIVE_CHUNK, total) - 1
111
+
112
+ try {
113
+ const response = await fetch(url, { headers: { Range: `bytes=${start}-${end}` } })
114
+ if (response.status !== 206) throw new Error(`HTTP ${response.status}`)
115
+
116
+ const body = await response.arrayBuffer()
117
+ const headers = new Headers()
118
+ headers.set("Content-Type", response.headers.get("Content-Type") || "application/octet-stream")
119
+ // On the response as well, because serving a range reads the total from here.
120
+ headers.set("coldwire-archive-total", String(total))
121
+
122
+ await cache.put(chunkKey(url, index, { total, size: body.byteLength }),
123
+ new Response(body, { status: 200, headers }))
124
+ } catch (error) {
125
+ // Everything already stored stays stored, so asking again resumes from here.
126
+ const failure = { ok: false, url, error: error.message, done: stored, total: count }
127
+ await notifyClients({ type: ARCHIVE_MESSAGE, state: "finished", ...failure })
128
+
129
+ return failure
130
+ }
131
+
132
+ stored += 1
133
+ notifyClients({ type: ARCHIVE_MESSAGE, url, state: "progress", done: stored, total: count })
134
+ }
135
+
136
+ const finished = { ok: true, url, done: stored, total: count, bytes: total, complete: true }
137
+ await notifyClients({ type: ARCHIVE_MESSAGE, state: "finished", ...finished })
138
+
139
+ return finished
140
+ }
141
+
142
+ async function removeArchive(url) {
143
+ const cache = await caches.open(CACHE_NAME)
144
+ const prefix = new URL(url)
145
+ prefix.search = ""
146
+
147
+ let removed = 0
148
+ for (const key of await cache.keys()) {
149
+ const keyUrl = new URL(key.url)
150
+ const bare = new URL(key.url)
151
+ bare.search = ""
152
+ if (bare.href !== prefix.href) continue
153
+ if (!keyUrl.searchParams.has(CHUNK_PARAM)) continue
154
+ if (await cache.delete(key)) removed += 1
155
+ }
156
+
157
+ return { ok: true, url, removed }
158
+ }
@@ -0,0 +1,79 @@
1
+ self.addEventListener("install", (event) => {
2
+ event.waitUntil(Promise.all([ self.skipWaiting(), cacheOfflinePage() ]))
3
+ })
4
+
5
+ // The offline page is the one page that has to render when nothing else can, and Hotwire
6
+ // Native rejects any page where window.Turbo never appears — so it carries an importmap and
7
+ // imports Turbo. Those assets are digested, which means a deploy changes their URLs and the
8
+ // cache filled by the previous deploy does not have the new ones.
9
+ //
10
+ // Left to a sync, the fallback is broken for exactly as long as nobody has been online since
11
+ // the deploy — which is to say, broken precisely when it is needed. Fetching them as the
12
+ // worker installs is the moment the new URLs first become known.
13
+ async function cacheOfflinePage() {
14
+ try {
15
+ const cache = await caches.open(CACHE_NAME)
16
+ const urls = urlsFromHtml(OFFLINE_PAGE, self.location.origin + "/")
17
+
18
+ await runPool(urls, SYNC_CONCURRENCY, async (href) => {
19
+ // One at a time and forgiving: a worker that refuses to install because a stylesheet
20
+ // was briefly unavailable is worse than one whose fallback is missing a stylesheet.
21
+ try {
22
+ await fetchAndCache(cache, href)
23
+ } catch {}
24
+ })
25
+ } catch {
26
+ // Installing must not fail over this. A worker with an imperfect fallback still caches
27
+ // pages, still syncs, and still beats no worker at all.
28
+ }
29
+ }
30
+
31
+ self.addEventListener("activate", (event) => {
32
+ event.waitUntil(self.clients.claim())
33
+ })
34
+
35
+ self.addEventListener("fetch", (event) => {
36
+ if (!shouldHandle(event.request)) return
37
+
38
+ if (event.request.headers.has("Range")) {
39
+ event.respondWith(handleRange(event.request))
40
+ return
41
+ }
42
+
43
+ event.respondWith(handleFetch(event.request, event))
44
+ })
45
+
46
+ self.addEventListener("message", (event) => {
47
+ const type = event.data?.type
48
+ const reply = (promise) => {
49
+ event.waitUntil(
50
+ Promise.resolve(promise)
51
+ .then((result) => event.ports[0]?.postMessage(result))
52
+ .catch((error) => event.ports[0]?.postMessage({ ok: false, error: error.message }))
53
+ )
54
+ }
55
+
56
+ if (type === "clearCache") return reply(clearCaches())
57
+ if (type === "forget") return reply(forgetUrl(event.data.url, event.data.cache))
58
+ if (type === "listCache") return reply(listCaches())
59
+ if (type === "getForcedOffline") return reply({ ok: true, forcedOffline })
60
+ if (type === "setForcedOffline") {
61
+ forcedOffline = Boolean(event.data.value)
62
+ return reply({ ok: true, forcedOffline })
63
+ }
64
+ if (type === "setCachingEnabled") {
65
+ cachingEnabled = Boolean(event.data.value)
66
+ return reply({ ok: true, cachingEnabled })
67
+ }
68
+ // One way to fill the cache, whether a page load asked for it on a timer or somebody
69
+ // pressed the button. Progress goes out on the broadcast, not this port, so every open
70
+ // page sees the run and not just whoever started it.
71
+ if (type === "sync") return reply(syncManifest())
72
+
73
+ // What a page missed by not listening yet, and whether it is still going on.
74
+ if (type === "syncState") return reply({ ok: true, running: Boolean(syncing), last: lastSyncMessage })
75
+
76
+ if (type === "archiveStatus") return reply(archiveStatus(event.data.url))
77
+ if (type === "archiveDownload") return reply(downloadArchive(event.data.url))
78
+ if (type === "archiveRemove") return reply(removeArchive(event.data.url))
79
+ })
@@ -0,0 +1,81 @@
1
+ // Drop one entry. Matched with the same options everything else uses, or a URL that was
2
+ // stored with a query string would refuse to be found by the one shown in the list.
3
+ async function forgetUrl(url, name) {
4
+ if (!url) return { ok: false, error: "No URL given" }
5
+
6
+ const cache = await caches.open(name || CACHE_NAME)
7
+ const deleted = await cache.delete(new Request(url), MATCH_OPTIONS)
8
+
9
+ return { ok: deleted, deleted }
10
+ }
11
+
12
+ async function clearCaches() {
13
+ const names = await caches.keys()
14
+ await Promise.all(names.map((name) => caches.delete(name)))
15
+ return { ok: true, cleared: names.length }
16
+ }
17
+
18
+ async function listCaches() {
19
+ const names = await caches.keys()
20
+ const result = []
21
+
22
+ for (const name of names) {
23
+ const cache = await caches.open(name)
24
+ const requests = await cache.keys()
25
+ const entries = []
26
+ for (const request of requests) {
27
+ entries.push(await describeCached(request, await cache.match(request, MATCH_OPTIONS)))
28
+ }
29
+ result.push({ name, entries })
30
+ }
31
+
32
+ return { ok: true, caches: result }
33
+ }
34
+
35
+ async function describeCached(request, response) {
36
+ return {
37
+ url: request.url,
38
+ size: await entrySize(response),
39
+ timestamp: unixTimestamp(request.headers.get(TIMESTAMP_HEADER))
40
+ }
41
+ }
42
+
43
+ // Ask the headers before reading the body. Listing a real cache means hundreds of entries and
44
+ // tens of megabytes, and blob() on every one of them is a multi-second job for a number that
45
+ // Content-Length already carries.
46
+ async function entrySize(response) {
47
+ if (!response) return 0
48
+
49
+ const declared = Number(response.headers.get("Content-Length"))
50
+ if (Number.isFinite(declared) && declared >= 0) return declared
51
+
52
+ return (await response.clone().blob()).size
53
+ }
54
+
55
+ // The key an entry is stored under.
56
+ //
57
+ // Cache does not honor HTTP freshness headers, and WebKit drops Date on match(). Stamp unix
58
+ // seconds on the request key — keys() returns it, and URL matching still finds the entry.
59
+ //
60
+ // When ignoring query params, drop the search here too, not just in MATCH_OPTIONS. Matching
61
+ // would find the entry either way, but every distinct query string would still write its own
62
+ // copy — a map that rewrites lat/lng/zoom on each pan would bury the cache in near-duplicates
63
+ // of one page.
64
+ function cacheKey(request, { managed = false } = {}) {
65
+ const headers = new Headers(request.headers)
66
+ headers.set(TIMESTAMP_HEADER, String(Math.floor(Date.now() / 1000)))
67
+ if (managed) headers.set(MANAGED_HEADER, "1")
68
+
69
+ // `new Request(request, init)` downgrades a navigation request's mode for us; rebuilding
70
+ // from a URL string needs the method stated explicitly.
71
+ if (!IGNORE_SEARCH) return new Request(request, { headers })
72
+
73
+ const url = new URL(request.url)
74
+ url.search = ""
75
+ return new Request(url.href, { method: "GET", headers })
76
+ }
77
+
78
+ function unixTimestamp(value) {
79
+ const seconds = Number(value)
80
+ return Number.isFinite(seconds) && seconds > 0 ? seconds : null
81
+ }
@@ -0,0 +1,136 @@
1
+ // Stitched out of whatever chunks are stored. Blob slices reference bytes rather than copying
2
+ // them, so answering a tile request out of a 300 MB archive costs about as much as answering
3
+ // it out of a single stored range.
4
+ async function rangeFromChunks(cache, request, spec) {
5
+ const bare = new URL(request.url)
6
+ bare.search = ""
7
+ if (!CACHE_ARCHIVES.includes(bare.href)) return null
8
+
9
+ const first = Math.floor(spec.start / ARCHIVE_CHUNK)
10
+ const parts = []
11
+ let total = null
12
+ let index = first
13
+ let wanted = spec.end === null ? Infinity : spec.end - spec.start + 1
14
+ let taken = 0
15
+
16
+ while (taken < wanted) {
17
+ const stored = await cache.match(chunkKey(bare.href, index))
18
+ // A gap: fall back rather than answer with a hole in the middle.
19
+ if (!stored) return null
20
+
21
+ total = total || Number(stored.headers.get("coldwire-archive-total")) || null
22
+ const blob = await stored.blob()
23
+ const offset = index * ARCHIVE_CHUNK
24
+ const from = Math.max(spec.start - offset, 0)
25
+ const to = wanted === Infinity ? blob.size : Math.min(from + (wanted - taken), blob.size)
26
+
27
+ parts.push(blob.slice(from, to))
28
+ taken += to - from
29
+ index += 1
30
+
31
+ // Ran off the end of the archive, which is a legitimate end to an open-ended range.
32
+ if (total && offset + blob.size >= total) break
33
+ if (to < blob.size) break
34
+ }
35
+
36
+ if (!parts.length) return null
37
+
38
+ const body = new Blob(parts)
39
+ const headers = new Headers()
40
+ const type = "application/octet-stream"
41
+ headers.set("Content-Type", type)
42
+ headers.set("Content-Length", String(body.size))
43
+ headers.set("Content-Range", `bytes ${spec.start}-${spec.start + body.size - 1}/${total || "*"}`)
44
+ headers.set("Accept-Ranges", "bytes")
45
+
46
+ return new Response(body, { status: 206, statusText: "Partial Content", headers })
47
+ }
48
+
49
+ // Cache first, always. A byte range of an archive is immutable for as long as the archive is,
50
+ // and the entire point of caching one is to stop asking for it.
51
+ async function handleRange(request) {
52
+ const spec = parseRange(request.headers.get("Range"))
53
+ // A single closed range is what a tile archive asks for. Multipart, or anything unparseable,
54
+ // is not something to take apart and reassemble — send it and store nothing.
55
+ if (!spec) return fetch(request)
56
+
57
+ const cache = await caches.open(CACHE_NAME)
58
+
59
+ // A fully downloaded archive answers everything, so try it before anything else.
60
+ const fromChunks = await rangeFromChunks(cache, request, spec)
61
+ if (fromChunks) return fromChunks
62
+
63
+ const key = rangeKey(request, spec)
64
+ // Deliberately not MATCH_OPTIONS: `ignore_query_params` would collapse every range of a
65
+ // file onto one entry, since the range lives in the query.
66
+ const stored = await cache.match(key, { ignoreVary: true })
67
+
68
+ if (stored) return partialResponse(stored, spec)
69
+ if (forcedOffline) return rangeUnavailable()
70
+
71
+ try {
72
+ const response = await fetch(request)
73
+ if (response.status !== 206) return response
74
+
75
+ // Clone before reading: the body is needed twice, once to store and once to return.
76
+ const body = await response.clone().arrayBuffer()
77
+ const headers = new Headers()
78
+ const type = response.headers.get("Content-Type")
79
+ if (type) headers.set("Content-Type", type)
80
+ headers.set(RANGE_TOTAL_HEADER, String(rangeTotal(response.headers.get("Content-Range")) ?? ""))
81
+ headers.set(TIMESTAMP_HEADER, String(Math.floor(Date.now() / 1000)))
82
+
83
+ cache.put(key, new Response(body, { status: 200, headers }))
84
+
85
+ return response
86
+ } catch {
87
+ return rangeUnavailable()
88
+ }
89
+ }
90
+
91
+ // The stored body *is* the range that was asked for, so this is only about the headers a
92
+ // partial response has to carry.
93
+ async function partialResponse(stored, spec) {
94
+ const body = await stored.arrayBuffer()
95
+ const total = stored.headers.get(RANGE_TOTAL_HEADER) || "*"
96
+ const headers = new Headers()
97
+ const type = stored.headers.get("Content-Type")
98
+ if (type) headers.set("Content-Type", type)
99
+ headers.set("Content-Length", String(body.byteLength))
100
+ headers.set("Content-Range", `bytes ${spec.start}-${spec.start + body.byteLength - 1}/${total}`)
101
+ headers.set("Accept-Ranges", "bytes")
102
+
103
+ return new Response(body, { status: 206, statusText: "Partial Content", headers })
104
+ }
105
+
106
+ // 504 rather than the offline page: whatever asked for a byte range wants bytes, and handing
107
+ // it HTML would be answered with a parse error instead of a failure it can report.
108
+ function rangeUnavailable() {
109
+ return new Response(null, { status: 504, statusText: "Offline" })
110
+ }
111
+
112
+ function parseRange(value) {
113
+ const match = /^bytes=(\d+)-(\d*)$/.exec(String(value || "").trim())
114
+ if (!match) return null
115
+
116
+ const start = Number(match[1])
117
+ const end = match[2] === "" ? null : Number(match[2])
118
+ if (end !== null && end < start) return null
119
+
120
+ return { start, end }
121
+ }
122
+
123
+ function rangeTotal(contentRange) {
124
+ const match = /\/(\d+)\s*$/.exec(String(contentRange || ""))
125
+
126
+ return match ? Number(match[1]) : null
127
+ }
128
+
129
+ // The range goes in the query so it is part of the cache key, and the request is never sent
130
+ // anywhere, so the extra parameter cannot confuse a server.
131
+ function rangeKey(request, spec) {
132
+ const url = new URL(request.url)
133
+ url.searchParams.set(RANGE_PARAM, `${spec.start}-${spec.end ?? ""}`)
134
+
135
+ return new Request(url.href, { method: "GET" })
136
+ }
@@ -0,0 +1,100 @@
1
+ function shouldHandle(request) {
2
+ if (!cachingEnabled) return false
3
+ if (request.method !== "GET") return false
4
+
5
+ const url = new URL(request.url)
6
+ if (!cacheableOrigin(url)) return false
7
+
8
+ // A Range request cannot be stored as it arrives — cache.put refuses a 206 — so it is
9
+ // stored as a 200 under a key naming the range, and answered with a 206 built here. Only
10
+ // for URLs nominated for it: everything else streams straight to the network, which is what
11
+ // you want for media, and leaves ordinary requests exactly as they were.
12
+ if (request.headers.has("Range")) return matchesRules(url, CACHE_RANGES)
13
+
14
+ return !matchesPath(url, NEVER_INTERCEPT)
15
+ }
16
+
17
+ // Our own origin, plus any the host app has nominated. A worker sees every request a page
18
+ // makes, and caching other people's responses uninvited is not its business.
19
+ function cacheableOrigin(url) {
20
+ return url.origin === self.location.origin || CACHE_ORIGINS.includes(url.origin)
21
+ }
22
+
23
+ function matchesPath(url, paths) {
24
+ return paths.some((path) => url.pathname === path || url.pathname.startsWith(`${path}/`))
25
+ }
26
+
27
+ // Rules arrive as plain objects so a Regexp survives the trip through JSON.
28
+ function compileRules(rules) {
29
+ return rules.map((rule) =>
30
+ rule.type === "regexp" ? new RegExp(rule.source, rule.flags) : segments(rule.value))
31
+ }
32
+
33
+ function segments(value) {
34
+ return value.split("/").filter(Boolean)
35
+ }
36
+
37
+ // Strings are route-shaped and match nothing beyond their own shape: "/sites" is /sites and
38
+ // not /sites/1, ":id" is exactly one segment, and a trailing "*" takes the rest. A Regexp is
39
+ // tested against the whole path.
40
+ //
41
+ // Deliberately strict. A prefix rule reads as "this section of the app", but it quietly takes
42
+ // everything underneath — search results, new/edit forms, nested collections — and with
43
+ // `ignore_query_params` a single "/sites/search" entry ends up answering every search.
44
+ function matchesRules(url, rules) {
45
+ return rules.some((rule) =>
46
+ rule instanceof RegExp
47
+ ? rule.test(url.pathname)
48
+ : matchesPattern(segments(url.pathname), rule)
49
+ )
50
+ }
51
+
52
+ function matchesPattern(path, pattern) {
53
+ for (let index = 0; index < pattern.length; index++) {
54
+ const part = pattern[index]
55
+
56
+ // "*" is only ever the last segment — the Ruby side refuses it anywhere else. A lone
57
+ // "/*" is every path, including "/". Anywhere else it takes everything remaining, so
58
+ // there has to be something remaining: "/sites/*" is not "/sites".
59
+ if (part === "*") return (index === 0 && pattern.length === 1) || path.length > index
60
+ if (index >= path.length) return false
61
+ if (part.charAt(0) === ":") continue
62
+ if (part !== path[index]) return false
63
+ }
64
+
65
+ return path.length === pattern.length
66
+ }
67
+
68
+ // Whether *automatic* caching may store this. The precache manifest deliberately skips this
69
+ // check: listing a URL there is an explicit instruction, and quietly declining it would make
70
+ // the manifest unpredictable.
71
+ // The one veto. Nothing stores a response for a URL named here — not browsing, not a page
72
+ // that references it, not the precache manifest. Meaningful only for our own paths: another
73
+ // origin's URLs are not ours to describe.
74
+ function isNeverCached(url) {
75
+ return url.origin === self.location.origin && matchesRules(url, NEVER_CACHE)
76
+ }
77
+
78
+ // Whether *browsing* to this stores it. Says nothing about the subresources of what it stores:
79
+ // a page comes with what it needs to render, which is not a second decision. The precache
80
+ // manifest skips this check entirely — listing a URL there is an explicit instruction.
81
+ function isAutoCacheable(request) {
82
+ const url = new URL(request.url)
83
+
84
+ // A nominated origin is the opt-in; the path lists describe this app's own surfaces and say
85
+ // nothing useful about somebody else's.
86
+ if (url.origin !== self.location.origin) return CACHE_ORIGINS.includes(url.origin)
87
+
88
+ if (isNeverCached(url)) return false
89
+
90
+ return matchesRules(url, CACHE_AS_YOU_GO)
91
+ }
92
+
93
+ // A followed redirect is the trap that breaks a signed-out cold launch. `cache.put()`
94
+ // stores one happily — it does NOT reject — so "/" ends up holding the sign-in page body,
95
+ // and the stored response keeps `redirected: true`. Serving that for a navigation is a
96
+ // network error by spec, so the app fails to launch offline rather than showing the
97
+ // cached page. Never store one.
98
+ function isCacheable(request, response) {
99
+ return request.method === "GET" && response.ok && !response.redirected
100
+ }