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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +53 -0
- data/LICENSE +21 -0
- data/README.md +69 -0
- data/VERSION +1 -0
- data/app/assets/javascripts/coldwire/archives.js +52 -0
- data/app/assets/javascripts/coldwire/cache_controller.js +1058 -0
- data/app/assets/javascripts/coldwire/entries.js +55 -0
- data/app/assets/javascripts/coldwire/format.js +60 -0
- data/app/assets/javascripts/coldwire/worker.js +35 -0
- data/app/controllers/coldwire/application_controller.rb +21 -0
- data/app/controllers/coldwire/caches_controller.rb +33 -0
- data/app/controllers/coldwire/service_worker_controller.rb +26 -0
- data/app/helpers/coldwire/service_worker_helper.rb +59 -0
- data/app/views/coldwire/caches/show.html.erb +304 -0
- data/app/views/coldwire/service_worker/offline_frame.html.erb +16 -0
- data/app/views/coldwire/service_worker/offline_page.html.erb +112 -0
- data/app/views/coldwire/service_worker/show.js.erb +66 -0
- data/config/importmap.rb +7 -0
- data/config/routes.rb +8 -0
- data/docs/README.md +19 -0
- data/docs/configuration.md +539 -0
- data/docs/how-it-works.md +64 -0
- data/docs/images/offline-fallback.png +0 -0
- data/docs/images/offline-settings-cached.png +0 -0
- data/docs/images/offline-settings.png +0 -0
- data/docs/setup.md +218 -0
- data/lib/coldwire/client/api.js +44 -0
- data/lib/coldwire/client/cookie.js +13 -0
- data/lib/coldwire/client/forced.js +17 -0
- data/lib/coldwire/client/identity.js +28 -0
- data/lib/coldwire/client/marker.js +35 -0
- data/lib/coldwire/client/register.js +19 -0
- data/lib/coldwire/client/store.js +69 -0
- data/lib/coldwire/client/sync.js +122 -0
- data/lib/coldwire/client_user_agent.rb +48 -0
- data/lib/coldwire/configuration.rb +319 -0
- data/lib/coldwire/debug.css +197 -0
- data/lib/coldwire/engine.rb +34 -0
- data/lib/coldwire/source.rb +50 -0
- data/lib/coldwire/version.rb +11 -0
- data/lib/coldwire/worker/archives.js +158 -0
- data/lib/coldwire/worker/events.js +79 -0
- data/lib/coldwire/worker/inspect.js +81 -0
- data/lib/coldwire/worker/ranges.js +136 -0
- data/lib/coldwire/worker/rules.js +100 -0
- data/lib/coldwire/worker/serve.js +226 -0
- data/lib/coldwire/worker/sync.js +234 -0
- data/lib/coldwire-rails.rb +5 -0
- data/lib/coldwire.rb +55 -0
- data/lib/generators/coldwire/install/install_generator.rb +110 -0
- data/lib/generators/coldwire/install/templates/coldwire.rb +53 -0
- data/lib/tasks/coldwire.rake +8 -0
- 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
|
+
}
|