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,112 @@
1
+ <%# Full-page offline fallback. Override by creating this same path in your own app.
2
+
3
+ Rendered when the worker script is built and embedded in it as a string, so this runs
4
+ without a real request: no current_user, no request-scoped helpers. %>
5
+ <!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <meta charset="utf-8">
9
+ <%# viewport-fit=cover is what makes env(safe-area-inset-*) resolve to anything. Without
10
+ it the layout viewport already sits inside the safe area and every inset reads 0, so
11
+ the padding below would silently do nothing. %>
12
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
13
+ <%# Served with a 200, so keep Turbo from snapshotting it and restoring it once back online. %>
14
+ <meta name="turbo-cache-control" content="no-cache">
15
+ <title>Offline</title>
16
+
17
+ <%# Hotwire Native reports "Turbo is not present" and shows its own error screen for any
18
+ page where window.Turbo never appears — a fallback without this is not renderable in
19
+ the app at all. Just Turbo, not the whole app entry point: every module in that graph
20
+ would have to be cached for it to evaluate. %>
21
+ <% if Coldwire.config.offline_import.present? && respond_to?(:javascript_inline_importmap_tag) %>
22
+ <%= javascript_inline_importmap_tag %>
23
+ <%= javascript_import_module_tag Coldwire.config.offline_import %>
24
+ <% end %>
25
+ </head>
26
+ <body>
27
+ <%# Deliberately in the body, and every selector scoped to .coldwire-offline.
28
+
29
+ Turbo's head merge copies new <style> elements in and never takes them out again —
30
+ only metas and titles are removed and replaced. A <style> in the head would therefore
31
+ install itself into the app for the rest of the session, and a bare `body { padding }`
32
+ rule would push every later page around until a full reload cleared it. In the body it
33
+ goes away with the body swap, and the scoping means it does nothing even if it does
34
+ somehow outlive this page. %>
35
+ <style>
36
+ .coldwire-offline {
37
+ /* Fixed rather than a body rule: fills the viewport whether or not the host's CSS
38
+ resets body margins, and without styling body itself. */
39
+ position: fixed;
40
+ top: 0;
41
+ right: 0;
42
+ bottom: 0;
43
+ left: 0;
44
+ overflow-y: auto;
45
+ box-sizing: border-box;
46
+ color-scheme: light dark;
47
+ font-family: system-ui, -apple-system, sans-serif;
48
+ background: #ffffff;
49
+ color: #1f2937;
50
+
51
+ /* Hotwire Native apps often run the web view edge to edge, pinning it to the top
52
+ anchor rather than the safe area, so keep the content clear of the notch and the
53
+ home indicator. Additive, so there is 2rem of breathing room measured from the
54
+ safe edge rather than from the screen edge. */
55
+ padding:
56
+ calc(2rem + env(safe-area-inset-top, 0px))
57
+ calc(2rem + env(safe-area-inset-right, 0px))
58
+ calc(2rem + env(safe-area-inset-bottom, 0px))
59
+ calc(2rem + env(safe-area-inset-left, 0px));
60
+ }
61
+ /* State everything rather than leaning on UA defaults. This page carries its own
62
+ styles and nothing else, so anything left unsaid is simply missing. */
63
+ .coldwire-offline h1 { font-size: 1.5rem; font-weight: 700; line-height: 1.25; margin: 0 0 0.5rem; }
64
+ .coldwire-offline p { font-size: 1rem; line-height: 1.5; margin: 0; }
65
+
66
+ .coldwire-offline-button {
67
+ display: inline-flex;
68
+ align-items: center;
69
+ gap: 0.5rem;
70
+ margin-top: 1.5rem;
71
+ border: 1px solid #1f2937;
72
+ border-radius: 0.5rem;
73
+ background: #1f2937;
74
+ color: #ffffff;
75
+ padding: 0.625rem 1.25rem;
76
+ font-family: inherit;
77
+ font-size: 1rem;
78
+ font-weight: 600;
79
+ line-height: 1.5;
80
+ text-decoration: none;
81
+ cursor: pointer;
82
+ }
83
+ .coldwire-offline-button svg {
84
+ width: 1.15rem;
85
+ height: 1.15rem;
86
+ flex-shrink: 0;
87
+ fill: none;
88
+ stroke: currentColor;
89
+ stroke-width: 1.5;
90
+ stroke-linecap: round;
91
+ stroke-linejoin: round;
92
+ }
93
+ </style>
94
+
95
+ <div class="coldwire-offline">
96
+ <h1>You're offline</h1>
97
+ <p>This page isn't available offline. Reconnect and try again.</p>
98
+
99
+ <%# An empty href resolves to whatever URL this page was served for, which is the one
100
+ thing this template cannot know — it is baked when the worker script is built.
101
+ data-turbo="false" forces a real navigation: a Turbo visit to the identical URL can
102
+ be treated as same-page and do nothing at all. %>
103
+ <a href="" data-turbo="false" class="coldwire-offline-button">
104
+ <svg viewBox="0 0 24 24" aria-hidden="true">
105
+ <path d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
106
+ </svg>
107
+ Try again
108
+ </a>
109
+ </div>
110
+
111
+ </body>
112
+ </html>
@@ -0,0 +1,66 @@
1
+ // Coldwire — offline page cache for Hotwire Native apps.
2
+ // Generated by the Coldwire engine; edit the gem, not this response.
3
+ //
4
+ // Built on the Cache API: https://developer.mozilla.org/en-US/docs/Web/API/Cache
5
+ //
6
+ // HTML visits recache through putFresh() even when a copy already exists — it retires the
7
+ // entry for that URL before writing, so one page is one entry however many Accepts asked for
8
+ // it — then fall back to cache.match() when offline. Assets stay cache-first. Precaching uses
9
+ // the same put/match path.
10
+ <%
11
+ offline_page = render(template: "coldwire/service_worker/offline_page", layout: false, formats: :html)
12
+ offline_frame = render(template: "coldwire/service_worker/offline_frame", layout: false, formats: :html)
13
+ %>
14
+
15
+ const CACHE_NAME = <%= raw Coldwire.config.cache_name.to_json %>
16
+ // Never intercepted: these go straight to the network, and fail outright when it is down.
17
+ const NEVER_INTERCEPT = <%= raw Coldwire.never_intercept(coldwire.service_worker_path, coldwire.pack_path, Coldwire.config.probe_path).to_json %>
18
+ // What browsing stores, and the one veto over storing anything at all. Neither governs the
19
+ // precache manifest, which is an explicit instruction.
20
+ const CACHE_AS_YOU_GO = compileRules(<%= raw Coldwire.cache_rules(Coldwire.config.cache_as_you_go).to_json %>)
21
+ const NEVER_CACHE = compileRules(<%= raw Coldwire.cache_rules(Coldwire.config.never_cache).to_json %>)
22
+ // Origins besides ours that may be cached at all, and the URLs whose byte ranges are cached
23
+ // piece by piece.
24
+ const CACHE_ORIGINS = <%= raw Coldwire.config.cache_origins.to_json %>
25
+ const CACHE_RANGES = compileRules(<%= raw Coldwire.cache_rules(Coldwire.config.cache_ranges).to_json %>)
26
+ // A range entry is stored under its own key, with the range in the query and the archive's
27
+ // total size in a header — the two things needed to rebuild a 206 that was never storable.
28
+ const RANGE_PARAM = "__coldwire_range"
29
+ // Archives offered for download in full, and the size of the pieces they arrive in. 8 MB is
30
+ // small enough that a dropped connection loses seconds rather than minutes, and large enough
31
+ // that a 300 MB archive is tens of requests rather than thousands.
32
+ const CACHE_ARCHIVES = <%= raw Coldwire.config.cache_archive_urls.to_json %>
33
+ const ARCHIVE_CHUNK = 8 * 1024 * 1024
34
+ const CHUNK_PARAM = "__coldwire_chunk"
35
+ const ARCHIVE_MESSAGE = "coldwire:archive"
36
+ const RANGE_TOTAL_HEADER = "coldwire-range-total"
37
+ const OFFLINE_PAGE = untrack(<%= raw offline_page.to_json %>)
38
+ const OFFLINE_FRAME_CONTENT = <%= raw offline_frame.to_json %>
39
+ const RETRY_URL_TOKEN = "coldwire:retry-url"
40
+
41
+ // Manifest sync.
42
+ const PACK_PATH = <%= raw coldwire.pack_path.to_json %>
43
+ const REFETCH_AFTER = <%= raw Coldwire.config.auto_sync.max_age ? Coldwire.config.auto_sync.max_age.to_i.to_json : "null" %>
44
+ const SYNC_CONCURRENCY = <%= raw Coldwire.config.auto_sync.concurrency.to_i.to_json %>
45
+ // Marks entries the manifest owns, so a sync can retire the ones it dropped without
46
+ // touching assets or pages picked up by ordinary browsing.
47
+ const MANAGED_HEADER = "coldwire-managed"
48
+ // One message type for the whole sync, carrying `state`. Two types would mean the page
49
+ // stamping the clock and the page drawing progress could drift apart.
50
+ const SYNC_MESSAGE = "coldwire:sync"
51
+
52
+ // Rails answers HTML with `Vary: Accept`, and cache.match() honors Vary by default.
53
+ // Precaching fetches with `Accept: */*` while Turbo navigations and frame loads send
54
+ // `Accept: text/html, application/xhtml+xml`, so a precached page would never match a real
55
+ // visit. ignoreVary makes matching URL-only, which is what a page cache wants.
56
+ const IGNORE_SEARCH = <%= raw Coldwire.config.ignore_query_params.to_json %>
57
+ const MATCH_OPTIONS = { ignoreVary: true, ignoreSearch: IGNORE_SEARCH }
58
+ const TIMESTAMP_HEADER = "timestamp"
59
+ const MARK_CACHED_PAGES = <%= raw Coldwire.config.mark_cached_pages.to_json %>
60
+ const OFFLINE_ATTRIBUTE = "data-coldwire-offline"
61
+ const CACHED_AT_ATTRIBUTE = "data-coldwire-cached-at"
62
+
63
+ let forcedOffline = false
64
+ let cachingEnabled = true
65
+
66
+ <%= raw Coldwire::Source.worker %>
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ pin "coldwire", to: "coldwire/cache_controller.js"
4
+ pin "coldwire/format", to: "coldwire/format.js"
5
+ pin "coldwire/worker", to: "coldwire/worker.js"
6
+ pin "coldwire/archives", to: "coldwire/archives.js"
7
+ pin "coldwire/entries", to: "coldwire/entries.js"
data/config/routes.rb ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ Coldwire::Engine.routes.draw do
4
+ # `format: false` keeps the literal ".js" in the path instead of parsing it as a format.
5
+ get "service-worker.js", to: "service_worker#show", as: :service_worker, format: false
6
+ get "pack", to: "caches#pack", as: :pack
7
+ root "caches#show"
8
+ end
data/docs/README.md ADDED
@@ -0,0 +1,19 @@
1
+ # Coldwire docs
2
+
3
+ Setup and configuration for [Coldwire](../README.md): the mountable Rails engine that
4
+ caches pages for Hotwire, PWAs, and Hotwire Native.
5
+
6
+ <p align="center">
7
+ <img src="images/offline-settings.png" alt="Offline settings: status, force offline, auto sync, and downloads" width="280">
8
+ <img src="images/offline-settings-cached.png" alt="Offline settings: every cached entry, with search, sort, and delete" width="280">
9
+ </p>
10
+
11
+ | | |
12
+ |---|---|
13
+ | [Setup](setup.md) | Install the gem, mount the engine, register the controller, add the layout tag |
14
+ | [Configuration](configuration.md) | Every option, its default, and what changing it does |
15
+ | [How it works](how-it-works.md) | Why a naive cache fails in Hotwire, and what Coldwire does about it |
16
+
17
+ Everything is set in `config/initializers/coldwire.rb` through `Coldwire.configure`.
18
+ `bin/rails coldwire:install` writes that file with every default. Only `auto_sync` really
19
+ needs your attention on a first install; `cache_identity` needs it if anyone signs in.