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,319 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Coldwire
6
+ # Host apps tune Coldwire through `Coldwire.configure`. Everything here has a working
7
+ # default except `precache_urls`, which only the host app can know.
8
+ class Configuration
9
+ # Name of the Cache API cache. Bump it to invalidate everything at once.
10
+ attr_accessor :cache_name
11
+
12
+ # Scope the worker claims. The worker is served from the engine mount point, so the
13
+ # response also sends `Service-Worker-Allowed` to widen it past that directory.
14
+ attr_accessor :worker_scope
15
+
16
+ # The importmap module the offline page loads. Hotwire Native reports "Turbo is not
17
+ # present" for any page where `window.Turbo` never appears, so the fallback has to boot
18
+ # Turbo — and Turbo alone, since one uncached module would fail the whole graph. nil if
19
+ # you are not on importmap-rails; load it yourself in the template instead.
20
+ attr_accessor :offline_import
21
+
22
+ # Stamp HTML served from cache with `data-coldwire-offline` and `data-coldwire-cached-at`,
23
+ # plus a <meta> that survives Turbo's head merge, so a page can say it is stale.
24
+ attr_accessor :mark_cached_pages
25
+
26
+ # Treat "/map" and "/map?zoom=9" as one cached page, matching and storing. Blunt: it also
27
+ # collapses `/search?q=`, so a cached result set can answer a different query.
28
+ attr_accessor :ignore_query_params
29
+
30
+ # What the offline settings page pings to tell online from offline, because navigator.onLine only
31
+ # reports whether an interface is up. Never intercepted — a probe answered from the cache
32
+ # would resolve with the network down, which is precisely backwards.
33
+ attr_accessor :probe_path
34
+
35
+ # Paths the worker does not touch at all, as prefix strings; the engine's own are added
36
+ # for you. The request goes straight to the network and so fails outright offline, showing
37
+ # the SDK's error screen rather than your offline page — which is what you want for a
38
+ # health check, and almost never what you want for a page.
39
+ #
40
+ # Not the same as `never_cache`, which is about storing. Compare:
41
+ #
42
+ # never_intercept the worker stands aside. No cache, no offline page, no fallback.
43
+ # never_cache the worker still answers, and can still show your offline page.
44
+ # It just never stores the response.
45
+ #
46
+ # So auth pages, admin, anything sensitive: `never_cache`. A probe the worker must
47
+ # never be able to answer from a cache: `never_intercept`.
48
+ attr_accessor :never_intercept
49
+
50
+ # What browsing stores. Strings are route patterns — "/sites/:id" matches that shape and
51
+ # nothing beneath it, "/assets/*" takes everything under it — and Regexps are tested
52
+ # against the path by JavaScript's RegExp, so write `^`/`$` rather than `\A`/`\z`.
53
+ #
54
+ # These are the pages worth keeping as somebody moves through the app. What one of them
55
+ # needs in order to render — its stylesheets, its scripts, its images — is stored with it
56
+ # whether or not those match anything here, because a page held without them is the
57
+ # offline equivalent of not holding it at all.
58
+ #
59
+ # Defaults to "/*", which is every path. An empty list stores nothing by browsing. It
60
+ # does not govern the precache manifest: listing a URL there is an explicit instruction.
61
+ attr_reader :cache_as_you_go
62
+
63
+ def cache_as_you_go=(patterns)
64
+ @cache_as_you_go = validate_patterns(patterns, :cache_as_you_go)
65
+ end
66
+
67
+ # Never stored, by any route in: not by browsing, not as a subresource of a page that
68
+ # references it, not by the precache manifest. The one veto.
69
+ attr_reader :never_cache
70
+
71
+ def never_cache=(patterns)
72
+ @never_cache = validate_patterns(patterns, :never_cache)
73
+ end
74
+
75
+ # Whether a page registers the worker at all — and so whether it caches or syncs anything.
76
+ # Evaluated in the view, so `request` and `current_user` are both in scope:
77
+ #
78
+ # config.register_if = -> { hotwire_native_app? && current_user.present? }
79
+ attr_writer :register_if
80
+
81
+ # Default for the Offline support switch on the offline settings page. People can turn
82
+ # it off there, which deletes what is stored and stops anything new being saved. Unset
83
+ # on a device follows this; a choice they have already made is remembered.
84
+ attr_accessor :caching_enabled_by_default
85
+
86
+ # Who the cache belongs to, usually the signed-in user's id. When it changes between page
87
+ # loads the cache is dropped, which is what makes signing out and switching accounts safe:
88
+ # cached pages hold whatever the previous session could see.
89
+ attr_writer :cache_identity
90
+
91
+ # Origins besides your own that the worker may cache. Each has to send CORS headers naming
92
+ # your app, or the response arrives opaque — status 0, no headers, no readable body — and
93
+ # there is nothing worth storing. Ranged sources must also expose Content-Range.
94
+ attr_reader :cache_origins
95
+
96
+ def cache_origins=(origins)
97
+ @cache_origins = Array(origins).map { |origin| validate_origin(origin) }
98
+ end
99
+
100
+ # URLs whose Range requests are cached piece by piece, keyed by the range — for a large
101
+ # immutable archive read a slice at a time, the slices you actually read are a rounding
102
+ # error next to the file. Same patterns as the lists above.
103
+ attr_reader :cache_ranges
104
+
105
+ def cache_ranges=(patterns)
106
+ @cache_ranges = validate_patterns(patterns, :cache_ranges) || Array(patterns)
107
+ end
108
+
109
+ # Large files somebody can choose to keep, each described well enough for the offline settings page
110
+ # to offer it without knowing what it is:
111
+ #
112
+ # config.cache_archives = [
113
+ # { url: "https://tiles.example.com/basemap.pmtiles", title: "Offline map",
114
+ # description: "The whole coast, rather than only the places you have opened." }
115
+ # ]
116
+ #
117
+ # A bare URL works too, with the filename as the title. Nothing downloads on its own:
118
+ # hundreds of megabytes over somebody's connection is their decision.
119
+ attr_reader :cache_archives
120
+
121
+ def cache_archives=(archives)
122
+ @cache_archives = Array(archives).map { |archive| normalize_archive(archive) }
123
+ end
124
+
125
+ # Just the URLs, for the worker — it downloads and serves; the words are the page's job.
126
+ def cache_archive_urls
127
+ cache_archives.map { |archive| archive[:url] }
128
+ end
129
+
130
+ # Keeping the cache current on its own. Grouped because these only mean anything together:
131
+ # a manifest with no interval is never fetched, an interval with no manifest has nothing
132
+ # to fetch.
133
+ #
134
+ # config.auto_sync do |sync|
135
+ # sync.enabled = true
136
+ # sync.precache_urls = -> { Site.published.map { |site| site_path(site) } }
137
+ # end
138
+ def auto_sync
139
+ @auto_sync ||= AutoSync.new
140
+ yield(@auto_sync) if block_given?
141
+
142
+ @auto_sync
143
+ end
144
+
145
+ # WebKit has no Background Sync, Periodic Background Sync or Background Fetch, so nothing
146
+ # can wake a worker. What a page load can do is hand work to one, which then runs on
147
+ # without it — so syncing is triggered by an open page and paced, not scheduled.
148
+ class AutoSync
149
+ # Off unless asked for. Background fetching is a decision about somebody's data plan.
150
+ attr_accessor :enabled
151
+
152
+ # The pages to keep cached, evaluated in the controller so route helpers and the current
153
+ # user are both available.
154
+ attr_accessor :precache_urls
155
+
156
+ # How long to leave between syncs.
157
+ attr_accessor :interval
158
+
159
+ # Refetch a manifest page once its copy is older than this. nil fetches only what is
160
+ # missing, so pages already cached are never noticed to have changed.
161
+ attr_accessor :max_age
162
+
163
+ # Lanes sharing a queue: sequential would take a round trip per URL, and all at once
164
+ # would stall the app's own requests behind hundreds of connections.
165
+ attr_accessor :concurrency
166
+
167
+ def initialize
168
+ @enabled = false
169
+ @precache_urls = -> { [] }
170
+ @interval = 6 * 60 * 60
171
+ @max_age = 7 * 24 * 60 * 60
172
+ @concurrency = 4
173
+ end
174
+ end
175
+
176
+ def initialize
177
+ @cache_name = "coldwire"
178
+ @worker_scope = "/"
179
+ @probe_path = "/up"
180
+ @never_intercept = [ "/up" ]
181
+ @cache_as_you_go = [ "/*" ]
182
+ @never_cache = []
183
+ # Propshaft and Sprockets, Webpacker, Vite, and Active Storage's signed blob URLs —
184
+ # every one of them addressed by something that changes when the bytes do.
185
+ @mark_cached_pages = true
186
+ @offline_import = "@hotwired/turbo-rails"
187
+ @ignore_query_params = true
188
+ @register_if = -> { true }
189
+ @caching_enabled_by_default = true
190
+ @cache_identity = -> { nil }
191
+ @cache_origins = []
192
+ @cache_ranges = []
193
+ @cache_archives = []
194
+ end
195
+
196
+ # Evaluated in the view, so `request` and `current_user` are both in scope. A block that
197
+ # declares a parameter is handed the request as well, which keeps `->(request) { ... }`
198
+ # working for anyone who only cares about the headers.
199
+ def register?(view)
200
+ result = if @register_if.arity.zero?
201
+ view.instance_exec(&@register_if)
202
+ else
203
+ view.instance_exec(view.request, &@register_if)
204
+ end
205
+
206
+ result ? true : false
207
+ end
208
+
209
+ # `view` is the view context, so a host can write `-> { current_user&.id }`.
210
+ def cache_identity(view)
211
+ view.instance_exec(&@cache_identity).to_s
212
+ end
213
+
214
+ private
215
+
216
+ # Fail here rather than when the worker script is rendered, so a bad pattern surfaces at
217
+ # boot instead of as a 500 that quietly takes caching down with it.
218
+ def validate_patterns(patterns, setting)
219
+ Array(patterns).each do |pattern|
220
+ next validate_path_pattern(pattern, setting) unless pattern.is_a?(Regexp)
221
+
222
+ # `\A` and friends are reflex for a Ruby developer, and JavaScript reads them as
223
+ # identity escapes — `\A` quietly becomes a literal "A" and the rule never matches.
224
+ if pattern.source.match?(/(?<!\\)\\[AzZ]/)
225
+ raise ArgumentError,
226
+ "Coldwire evaluates #{setting} patterns with JavaScript's RegExp, which reads " \
227
+ "\\A, \\z and \\Z as literal letters. Use ^ and $ instead: #{pattern.inspect}"
228
+ end
229
+
230
+ unsupported = []
231
+ unsupported << "x (extended)" if pattern.options.anybits?(Regexp::EXTENDED)
232
+ unsupported << "m (multiline)" if pattern.options.anybits?(Regexp::MULTILINE)
233
+ next if unsupported.empty?
234
+
235
+ raise ArgumentError,
236
+ "Coldwire evaluates #{setting} patterns with JavaScript's RegExp, which has no " \
237
+ "equivalent for #{unsupported.join(' and ')}: #{pattern.inspect}"
238
+ end
239
+ end
240
+
241
+ # An origin and nothing more: no path, no trailing slash. Anything else silently fails to
242
+ # match a request's origin, which is the same quiet failure as a malformed path pattern.
243
+ def validate_origin(origin)
244
+ value = origin.to_s
245
+
246
+ begin
247
+ uri = URI.parse(value)
248
+ rescue URI::InvalidURIError
249
+ uri = nil
250
+ end
251
+
252
+ unless uri&.scheme && uri.host && uri.path.to_s.empty? && uri.query.nil?
253
+ raise ArgumentError,
254
+ "Coldwire cache_origins takes bare origins like " \
255
+ "\"https://tiles.example.com\": #{origin.inspect}"
256
+ end
257
+
258
+ value
259
+ end
260
+
261
+ # A Hash with a url, or a bare URL string. Title falls back to the filename, which is a
262
+ # poor title but a better one than a blank card.
263
+ def normalize_archive(archive)
264
+ archive = { url: archive } unless archive.is_a?(Hash)
265
+ archive = archive.transform_keys(&:to_sym)
266
+ url = archive[:url].to_s
267
+
268
+ begin
269
+ uri = URI.parse(url)
270
+ rescue URI::InvalidURIError
271
+ uri = nil
272
+ end
273
+
274
+ unless uri&.scheme && uri.host
275
+ raise ArgumentError,
276
+ "Coldwire cache_archives needs an absolute url for each entry: #{archive.inspect}"
277
+ end
278
+
279
+ {
280
+ url: url,
281
+ title: archive[:title].presence || File.basename(uri.path.to_s).presence || url,
282
+ description: archive[:description].presence
283
+ }
284
+ end
285
+
286
+ # Path patterns are route-shaped: literal segments, ":name" for exactly one segment, and a
287
+ # trailing "*" for the rest. Checked here because every mistake in this shape fails the
288
+ # same silent way — the rule simply never matches, and you find out when something you
289
+ # expected to be there offline is not.
290
+ def validate_path_pattern(pattern, setting)
291
+ path = pattern.to_s
292
+
293
+ unless path.start_with?("/")
294
+ raise ArgumentError,
295
+ "Coldwire #{setting} paths are matched from the root, so they start with a " \
296
+ "slash: #{pattern.inspect}"
297
+ end
298
+
299
+ parts = path.split("/").reject(&:empty?)
300
+
301
+ parts.each_with_index do |part, index|
302
+ next if part == "*" && index == parts.length - 1
303
+
304
+ if part == "*"
305
+ raise ArgumentError,
306
+ "Coldwire #{setting} \"*\" matches everything remaining, so it can only be " \
307
+ "the last segment: #{pattern.inspect}"
308
+ end
309
+
310
+ next if part.match?(/\A:[A-Za-z_]\w*\z/)
311
+ next if part.match?(/\A[^:*]+\z/)
312
+
313
+ raise ArgumentError,
314
+ "Coldwire #{setting} segment #{part.inspect} is not a literal, a \":name\", or " \
315
+ "a trailing \"*\": #{pattern.inspect}"
316
+ end
317
+ end
318
+ end
319
+ end
@@ -0,0 +1,197 @@
1
+ .coldwire { --coldwire-caret: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m19.5 8.25-7.5 7.5-7.5-7.5'/%3E%3C/svg%3E");
2
+ max-width: 42rem; margin: 0 auto; padding: 0.75rem;
3
+ font-family: system-ui, -apple-system, sans-serif; color: #1f2937; }
4
+ .coldwire h1 { font-size: 1.5rem; margin: 0 0 1rem; }
5
+ .coldwire-card { border: 1px solid #e5e7eb; border-radius: 1rem; background: #fff;
6
+ padding: 1rem; margin-bottom: 0.75rem; }
7
+ .coldwire-card h2 { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em;
8
+ text-transform: uppercase; color: #9ca3af; margin: 0 0 0.75rem; }
9
+
10
+ /* Status: the one thing worth reading at a glance, so it gets the largest type on the
11
+ page and the colour. */
12
+ .coldwire-headline { display: flex; align-items: center; gap: 0.6rem;
13
+ font-size: 1.125rem; font-weight: 600; }
14
+ .coldwire-light { flex-shrink: 0; width: 0.6rem; height: 0.6rem; border-radius: 9999px;
15
+ background: #9ca3af; box-shadow: 0 0 0 3px rgba(156, 163, 175, 0.18); }
16
+ .coldwire-light[data-state="online"] { background: #16a34a; box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.18); }
17
+ .coldwire-light[data-state="forced"] { background: #f59e0b; box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.20); }
18
+ .coldwire-light[data-state="offline"] { background: #dc2626; box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.18); }
19
+ /* Grey, not red: switched off is not a fault. */
20
+ .coldwire-light[data-state="disabled"] { background: #9ca3af; box-shadow: 0 0 0 3px rgba(156, 163, 175, 0.18); }
21
+ .coldwire-facts code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8125rem; }
22
+ .coldwire-facts { margin: 0.5rem 0 0; font-size: 0.875rem; color: #6b7280; line-height: 1.5; }
23
+
24
+ .coldwire-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 0.75rem; }
25
+ /* Offline support lives in the status card. The rule only applies while the Online/offline
26
+ row is showing — with support off that row is hidden and the switch is the whole card. */
27
+ .coldwire-head:not([hidden]) + .coldwire-setting { margin-top: 0.9rem; padding-top: 0.9rem;
28
+ border-top: 1px solid #e5e7eb; }
29
+ .coldwire-card hr { border: 0; border-top: 1px solid #e5e7eb; margin: 0.9rem 0; }
30
+ .coldwire-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
31
+ .coldwire-actions--spaced { margin-top: 0.75rem; }
32
+
33
+ .coldwire-button { display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
34
+ cursor: pointer; border: 1px solid #d1d5db; border-radius: 0.5rem; background: #fff; color: #111827;
35
+ padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 600; line-height: 1.5rem; white-space: nowrap; }
36
+ .coldwire-button[disabled] { opacity: 0.6; cursor: default; }
37
+ /* `hidden` is a UA rule, and any author `display` beats it — so the Delete button, which
38
+ sets its own, ignored the attribute and showed on an archive nobody had downloaded.
39
+ Stated once for the whole page rather than per component that grows a display. */
40
+ .coldwire [hidden] { display: none !important; }
41
+ .coldwire-button--danger { color: #b91c1c; border-color: #fecaca; }
42
+ .coldwire-icon-button { width: 2.25rem; height: 2.25rem; padding: 0; flex-shrink: 0; }
43
+ /* Heroicons outline, which are drawn for 1.5. Set here rather than on each svg: CSS beats
44
+ presentation attributes, so a value here silently wins anyway. */
45
+ .coldwire-button svg { width: 1rem; height: 1rem; flex-shrink: 0; fill: none;
46
+ stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
47
+ .coldwire-icon-button svg { width: 1.2rem; height: 1.2rem; fill: none; stroke: currentColor;
48
+ stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
49
+ /* Spins while a refresh is in flight — without it the button re-reads values that usually
50
+ look identical and seems to do nothing. */
51
+ .coldwire-icon-button[data-busy] svg { animation: coldwire-spin 0.7s linear infinite; }
52
+
53
+ .coldwire-spinner { width: 1rem; height: 1rem; flex-shrink: 0; border-radius: 9999px;
54
+ border: 2px solid #d1d5db; border-top-color: #374151; animation: coldwire-spin 0.7s linear infinite; }
55
+ @keyframes coldwire-spin { to { transform: rotate(360deg); } }
56
+
57
+ /* A real checkbox underneath, so it keeps its keyboard behaviour and its label. */
58
+ .coldwire-switch { position: relative; display: inline-flex; flex-shrink: 0;
59
+ width: 2.6rem; height: 1.55rem; }
60
+ .coldwire-switch input { position: absolute; inset: 0; width: 100%; height: 100%;
61
+ margin: 0; opacity: 0; cursor: pointer; }
62
+ .coldwire-switch span { position: absolute; inset: 0; pointer-events: none; border-radius: 9999px;
63
+ background: #d1d5db; transition: background-color 160ms ease; }
64
+ .coldwire-switch span::after { content: ""; position: absolute; top: 0.2rem; left: 0.2rem;
65
+ width: 1.15rem; height: 1.15rem; border-radius: 9999px; background: #fff;
66
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); transition: transform 160ms ease; }
67
+ /* Amber, matching the forced-offline light — the two say the same thing. */
68
+ .coldwire-switch input:checked + span { background: #f59e0b; }
69
+ /* Except where being on is the ordinary state rather than a deliberate degradation; green,
70
+ matching the online light. Same switch otherwise. */
71
+ .coldwire-switch--on input:checked + span { background: #16a34a; }
72
+ .coldwire-switch input:checked + span::after { transform: translateX(1.05rem); }
73
+ .coldwire-switch input:focus-visible + span { outline: 2px solid #2563eb; outline-offset: 2px; }
74
+
75
+ .coldwire-setting { display: flex; align-items: flex-start; gap: 0.85rem; cursor: pointer; }
76
+ /* A setting that leads a card, with facts under it rather than standing alone. */
77
+ .coldwire-setting + .coldwire-facts { margin-top: 0.9rem; }
78
+ .coldwire-setting-title { display: block; font-weight: 600; }
79
+ .coldwire-setting-note { display: block; margin-top: 0.15rem; font-size: 0.875rem; color: #6b7280; }
80
+
81
+ .coldwire-track { height: 0.375rem; width: 100%; overflow: hidden; border-radius: 9999px; background: #e5e7eb; }
82
+ .coldwire-bar { height: 100%; width: 100%; border-radius: 9999px; background: #374151; transition: width 200ms; }
83
+ .coldwire-pulse { animation: coldwire-fade 1.4s ease-in-out infinite; }
84
+ @keyframes coldwire-fade { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
85
+ .coldwire-progress-label { margin-top: 0.375rem; font-size: 0.75rem; color: #6b7280; }
86
+
87
+ /* Search and sort sit above the list rather than beside the heading: on a phone the two
88
+ controls need the full width between them, and the heading is only a label. */
89
+ .coldwire-filters { display: flex; gap: 0.5rem; margin-bottom: 0.75rem; }
90
+ .coldwire-input, .coldwire-select { border: 1px solid #d1d5db; border-radius: 0.5rem;
91
+ background-color: #fff; color: #111827; padding: 0.375rem 0.5rem; font-size: 0.875rem;
92
+ line-height: 1.5rem; font-family: inherit; }
93
+ /* The search takes the slack; the select asks only for its own content. 0 min-width lets it
94
+ shrink inside the flex row instead of forcing the card wider than the screen. */
95
+ .coldwire-input { flex: 1 1 auto; min-width: 0; }
96
+
97
+ /* Safari stops drawing the native arrow once a select is given a background and a border,
98
+ so opt out of the native look entirely and draw one. Heroicons chevron-down, to match the
99
+ other icons on the page. */
100
+ .coldwire-select { flex: 0 0 auto; cursor: pointer;
101
+ appearance: none; -webkit-appearance: none;
102
+ padding-right: 1.9rem;
103
+ background-image: var(--coldwire-caret);
104
+ background-repeat: no-repeat;
105
+ background-position: right 0.55rem center;
106
+ background-size: 0.85rem; }
107
+ .coldwire-input:focus-visible, .coldwire-select:focus-visible { outline: 2px solid #6b7280;
108
+ outline-offset: 1px; }
109
+
110
+ /* The rule separates one download from the next; the first has nothing above it. */
111
+ .coldwire-archive { margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb; }
112
+ .coldwire-archive:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
113
+ .coldwire-archive-title { font-size: 0.9375rem; font-weight: 600; }
114
+ /* The buttons used to sit under a status line that spaced them; they lead now. */
115
+ .coldwire-archive .coldwire-actions { margin-top: 0.75rem; }
116
+ /* What pressing them got you, reading under them rather than above. */
117
+ .coldwire-archive-meta { margin-top: 0.6rem; font-size: 0.8125rem; }
118
+ /* Under the filter, because it answers what the filter asks. */
119
+ .coldwire-count { margin: 0 0 0.6rem; }
120
+
121
+ .coldwire-entries { list-style: none; margin: 0; padding: 0; max-height: 32rem; overflow-y: auto; }
122
+ .coldwire-entries li + li { margin-top: 0.5rem; }
123
+ .coldwire-entry { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
124
+ /* A button, not a div: the whole point is that it is tappable, and this way it is reachable
125
+ from a keyboard and announced as a control without any of that being reinvented. It takes
126
+ the slack and is allowed to shrink, so a long path ellipsises rather than pushing the
127
+ trash off the row. */
128
+ .coldwire-entry-text { flex: 1 1 auto; min-width: 0; display: block; text-align: left;
129
+ padding: 0; border: 0; background: none; font: inherit; color: inherit; cursor: pointer; }
130
+ .coldwire-entry-text:focus-visible { outline: 2px solid #6b7280; outline-offset: 2px;
131
+ border-radius: 0.25rem; }
132
+ .coldwire-entry-forget { flex: 0 0 auto; display: inline-flex; align-items: center;
133
+ justify-content: center; width: 2rem; height: 2rem; padding: 0; cursor: pointer;
134
+ border: 0; border-radius: 0.5rem; background: none; color: #9ca3af; }
135
+ .coldwire-entry-forget svg { width: 1rem; height: 1rem; fill: none; stroke: currentColor;
136
+ stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
137
+ /* Quiet until you go for it: one of these sits on every row, and a column of red would
138
+ read as a warning rather than a control. */
139
+ .coldwire-entry-forget:hover, .coldwire-entry-forget:focus-visible { color: #b91c1c;
140
+ background: #fef2f2; }
141
+ .coldwire-entry-forget[disabled] { opacity: 0.5; cursor: default; }
142
+ .coldwire-entry-url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
143
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.75rem; color: #1f2937; }
144
+ .coldwire-entry-meta { margin-top: 0.125rem; font-size: 0.75rem; color: #6b7280; }
145
+ .coldwire-empty { color: #9ca3af; font-size: 0.875rem; }
146
+ /* Centred here rather than left to the browser. A modal <dialog> is centred by the UA's
147
+ `margin: auto`, and a host app's CSS reset routinely zeroes margin on everything —
148
+ Tailwind's preflight does — which drops the dialog into the top left corner. A definite
149
+ width and height are what let `margin: auto` centre on both axes. */
150
+ .coldwire-dialog { position: fixed; inset: 0; margin: auto;
151
+ width: min(28rem, calc(100vw - 2rem)); height: fit-content;
152
+ max-height: calc(100vh - 2rem); overflow: auto;
153
+ border: 0; border-radius: 1rem; padding: 1rem; background: #fff; color: #1f2937;
154
+ font-family: inherit; }
155
+ .coldwire-dialog::backdrop { background: rgba(17, 24, 39, 0.45); }
156
+ .coldwire-dialog h2 { font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em;
157
+ text-transform: uppercase; color: #9ca3af; margin: 0 0 0.75rem; }
158
+ /* The reason this exists: a URL too long for its row has to wrap here, anywhere it must,
159
+ rather than ellipsising again or pushing the dialog wider than the screen. */
160
+ .coldwire-dialog-url { margin: 0; font-size: 0.875rem; line-height: 1.5;
161
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
162
+
163
+ /* Quiet on purpose: a list of every cached URL is a debug surface, not a setting.
164
+ No card, no colour, just a disclosure at the bottom for whoever needs it. */
165
+ .coldwire-inspect { margin: 1.25rem 0 0; padding-top: 0.85rem; border-top: 1px solid #e5e7eb; }
166
+ .coldwire-inspect summary { cursor: pointer; list-style: none; }
167
+ .coldwire-inspect summary::-webkit-details-marker { display: none; }
168
+ .coldwire-inspect-label { display: flex; align-items: center; gap: 0.35rem;
169
+ font-size: 0.75rem; font-weight: 600; letter-spacing: 0.04em;
170
+ text-transform: uppercase; color: #9ca3af; }
171
+ .coldwire-inspect-label::after { content: ""; width: 0.8rem; height: 0.8rem; flex-shrink: 0;
172
+ background: var(--coldwire-caret) no-repeat center / contain;
173
+ opacity: 0.7; transition: transform 160ms ease; }
174
+ .coldwire-inspect[open] .coldwire-inspect-label::after { transform: rotate(180deg); }
175
+ .coldwire-inspect-hint { display: block; margin-top: 0.15rem;
176
+ font-size: 0.8125rem; color: #9ca3af; line-height: 1.4; }
177
+ .coldwire-inspect-body { margin-top: 0.85rem; }
178
+ .coldwire-inspect summary:focus-visible { outline: 2px solid #6b7280; outline-offset: 2px;
179
+ border-radius: 0.25rem; }
180
+
181
+ /* A phone gives up a lot of its width to two sets of gutters — the page's and each card's.
182
+ Tighten both, and let the cards run closer to the edges where the room is worth more
183
+ than the margin. */
184
+ @media (max-width: 30rem) {
185
+ .coldwire { padding: 0.5rem 0.25rem; }
186
+ .coldwire h1 { font-size: 1.375rem; margin-bottom: 0.75rem; }
187
+ .coldwire-card { padding: 0.75rem; border-radius: 0.75rem; }
188
+ }
189
+
190
+ @media (prefers-reduced-motion: reduce) {
191
+ .coldwire-icon-button[data-busy] svg,
192
+ .coldwire-spinner,
193
+ .coldwire-pulse { animation: none; }
194
+ .coldwire-switch span,
195
+ .coldwire-switch span::after,
196
+ .coldwire-inspect-label::after { transition: none; }
197
+ }
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coldwire
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace Coldwire
6
+
7
+ # Let host apps `pin "coldwire", to: "coldwire/cache_controller.js"` without knowing
8
+ # where the gem lives.
9
+ initializer "coldwire.importmap", before: "importmap" do |app|
10
+ if app.config.respond_to?(:importmap)
11
+ app.config.importmap.paths << root.join("config/importmap.rb")
12
+ app.config.importmap.cache_sweepers << root.join("app/assets/javascripts")
13
+ end
14
+ end
15
+
16
+ initializer "coldwire.assets" do |app|
17
+ if app.config.respond_to?(:assets)
18
+ app.config.assets.paths << root.join("app/assets/javascripts")
19
+ end
20
+ end
21
+
22
+ # Ahead of everything, so whatever asks whether this is the native app — the layout, the
23
+ # helpers, `register_if` — sees the client the request actually came from.
24
+ initializer "coldwire.client_user_agent" do |app|
25
+ app.config.middleware.insert_before 0, Coldwire::ClientUserAgent
26
+ end
27
+
28
+ initializer "coldwire.helpers" do
29
+ ActiveSupport.on_load(:action_controller_base) do
30
+ helper Coldwire::ServiceWorkerHelper
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Coldwire
6
+ # JavaScript and CSS that ship as files and are assembled when they are rendered, so none of
7
+ # it has to be written inside a Ruby string.
8
+ module Source
9
+ ROOT = Pathname.new(__dir__)
10
+
11
+ # The worker is served as one script but written as several. Concatenated rather than
12
+ # imported, so the browser still fetches one file and function declarations hoist across
13
+ # the whole of it.
14
+ WORKER = %w[rules serve ranges archives inspect sync events].freeze
15
+
16
+ class << self
17
+ def worker
18
+ assemble(WORKER.map { |part| read("worker/#{part}.js") })
19
+ end
20
+
21
+ # The page-side script: the values it cannot know, then whichever parts this page needs.
22
+ #
23
+ # A property rather than a const. Turbo copies head scripts it does not recognise, and a
24
+ # per-request CSP nonce makes this one look new on every visit — a second `const COLDWIRE`
25
+ # in the same document is a SyntaxError, and the whole script dies with it.
26
+ def client(config, parts)
27
+ assemble([ "window.COLDWIRE = #{config.to_json};" ] + parts.map { |part| read("client/#{part}.js") })
28
+ end
29
+
30
+ def debug_css
31
+ read("debug.css")
32
+ end
33
+
34
+ private
35
+
36
+ # Spelled out rather than left to the host's locale: these files have em dashes in them,
37
+ # and a process running under a C locale would otherwise read them as US-ASCII and raise
38
+ # on the first one.
39
+ def read(path)
40
+ ROOT.join(path).read(encoding: "UTF-8")
41
+ end
42
+
43
+ # Each fragment is self-terminated. `})()` followed by `(function` reads as one call
44
+ # expression rather than two statements, and ASI does not save you.
45
+ def assemble(parts)
46
+ parts.join("\n")
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Coldwire
4
+ # Read from the VERSION file at the root, which is the single place the number is written:
5
+ # the gemspec takes it from here rather than from a second copy, and a release only has to
6
+ # touch one line.
7
+ #
8
+ # `.freeze` because the frozen_string_literal pragma above covers literals in this file, not
9
+ # a string that arrives from IO.
10
+ VERSION = File.read(File.expand_path("../../VERSION", __dir__)).strip.freeze
11
+ end