active_storage-async_variants 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +2 -22
  4. data/Rakefile +9 -1
  5. data/app/assets/javascripts/retry.js +11 -0
  6. data/app/assets/stylesheets/retry.css +98 -0
  7. data/app/controllers/active_storage/async_variants/states_controller.rb +62 -0
  8. data/app/views/active_storage/async_variants/states/_failed.html.erb +43 -0
  9. data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
  10. data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
  11. data/app/views/active_storage/async_variants/states/_processing.html.erb +9 -0
  12. data/app/views/active_storage/async_variants/states/show.html.erb +10 -0
  13. data/config/routes.rb +9 -0
  14. data/features/retry_flow.feature +20 -0
  15. data/features/retry_visibility.feature +23 -0
  16. data/features/state_rendering.feature +22 -0
  17. data/features/step_definitions/dummy_steps.rb +116 -0
  18. data/features/support/env.rb +28 -0
  19. data/gemfiles/rails_7.2.gemfile +4 -0
  20. data/gemfiles/rails_8.0.gemfile +4 -0
  21. data/gemfiles/rails_8.1.gemfile +4 -0
  22. data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +38 -56
  23. data/lib/active_storage/async_variants/helper.rb +59 -0
  24. data/lib/active_storage/async_variants/preview_extension.rb +35 -32
  25. data/lib/active_storage/async_variants/variant_with_record_extension.rb +40 -30
  26. data/lib/active_storage/async_variants/version.rb +1 -1
  27. data/lib/active_storage/async_variants.rb +26 -15
  28. metadata +44 -4
  29. data/app/assets/javascripts/active_storage_async_variants.js +0 -169
  30. data/lib/active_storage/async_variants/representations_redirect_controller_extension.rb +0 -26
@@ -1,169 +0,0 @@
1
- // active-storage-async_variants
2
- // Renders the correct fallback for async ActiveStorage variants, and polls
3
- // for completion. Pairs with the gem's image_tag/video_tag `async:` /
4
- // `direct:` options.
5
-
6
- const STATE_ATTR = "data-async-variant-state-value"
7
- const SRC_ATTR = "data-async-variant-src-value"
8
- const DIRECT_ATTR = "data-async-variant-direct-value"
9
- const SELECTOR = `[${STATE_ATTR}]`
10
- const HEADER = "X-Async-Variant-State"
11
- const POLLABLE_STATES = ["pending", "processing"]
12
- const POLL_BASE_MS = 3000
13
- const MAX_POLLS = 10
14
- const RETRY_BASE_MS = 10000
15
- const MAX_RETRIES = 3
16
-
17
- const isSafari = typeof navigator !== "undefined" &&
18
- /^((?!chrome|android).)*safari/i.test(navigator.userAgent)
19
-
20
- const elementState = new WeakMap()
21
-
22
- function stateFor(el) {
23
- let s = elementState.get(el)
24
- if (!s) {
25
- s = { pollTimer: null, pollCount: 0, retries: 0 }
26
- elementState.set(el, s)
27
- }
28
- return s
29
- }
30
-
31
- function srcUrl(el) { return el.getAttribute(SRC_ATTR) }
32
- function directUrl(el) { return el.getAttribute(DIRECT_ATTR) }
33
- function variantState(el) { return el.getAttribute(STATE_ATTR) }
34
-
35
- async function fetchAsyncState(url) {
36
- if (!url) return null
37
- try {
38
- const response = await fetch(url, { method: "HEAD", redirect: "manual", cache: "no-store" })
39
- if (response.type === "opaqueredirect") return null
40
- return response.headers.get(HEADER)
41
- } catch {
42
- return null
43
- }
44
- }
45
-
46
- function schedulePoll(el) {
47
- const s = stateFor(el)
48
- s.pollTimer = setTimeout(() => poll(el), POLL_BASE_MS * Math.pow(2, s.pollCount))
49
- }
50
-
51
- function stopPolling(el) {
52
- const s = elementState.get(el)
53
- if (s && s.pollTimer) {
54
- clearTimeout(s.pollTimer)
55
- s.pollTimer = null
56
- }
57
- }
58
-
59
- function startPolling(el) {
60
- const s = stateFor(el)
61
- if (s.pollTimer) return
62
- s.pollCount = 0
63
- schedulePoll(el)
64
- }
65
-
66
- async function poll(el) {
67
- const state = await fetchAsyncState(srcUrl(el))
68
- const s = stateFor(el)
69
- s.pollTimer = null
70
- if (POLLABLE_STATES.includes(state)) {
71
- s.pollCount += 1
72
- if (s.pollCount < MAX_POLLS) schedulePoll(el)
73
- return
74
- }
75
- if (state === "failed") {
76
- el.setAttribute(STATE_ATTR, "failed")
77
- return
78
- }
79
- const target = directUrl(el) || srcUrl(el)
80
- if (!target) return
81
- el.setAttribute(STATE_ATTR, "processed")
82
- el.src = target + (target.includes("?") ? "&" : "?") + "_t=" + Date.now()
83
- }
84
-
85
- function fallback(el) {
86
- const url = srcUrl(el)
87
- if (url) el.setAttribute("src", url)
88
- }
89
-
90
- function onLoad(event) {
91
- const el = event.target
92
- if (!el.matches || !el.matches(SELECTOR)) return
93
- if (POLLABLE_STATES.includes(variantState(el))) startPolling(el)
94
- }
95
-
96
- function onError(event) {
97
- const el = event.target
98
- if (!el.matches || !el.matches(SELECTOR)) return
99
- const s = stateFor(el)
100
- if (s.retries >= MAX_RETRIES) return
101
- setTimeout(() => fallback(el), RETRY_BASE_MS * Math.pow(2, s.retries))
102
- s.retries += 1
103
- }
104
-
105
- function setupVideo(el) {
106
- if (el.nodeName !== "VIDEO") return
107
- if (isSafari) fallback(el)
108
- // Browser autoplay-via-attribute is permitted on first load, but the
109
- // attribute also makes Turbo's cloneNode(true) snapshot start playback on
110
- // the detached clone (ghost audio). Strip the attribute after insertion --
111
- // playback is already scheduled -- and rely on play() here for restored
112
- // snapshots (which won't have the attribute anymore).
113
- // https://github.com/hotwired/turbo/issues/1017
114
- if (el.hasAttribute("autoplay")) {
115
- el.removeAttribute("autoplay")
116
- const promise = el.play()
117
- if (promise && typeof promise.catch === "function") promise.catch(() => {})
118
- }
119
- }
120
-
121
- function processAdded(node) {
122
- if (node.nodeType !== 1) return
123
- if (node.matches && node.matches(SELECTOR)) setupVideo(node)
124
- if (node.querySelectorAll) node.querySelectorAll(SELECTOR).forEach(setupVideo)
125
- }
126
-
127
- function processRemoved(node) {
128
- if (node.nodeType !== 1) return
129
- if (node.matches && node.matches(SELECTOR)) {
130
- stopPolling(node)
131
- elementState.delete(node)
132
- }
133
- if (node.querySelectorAll) {
134
- node.querySelectorAll(SELECTOR).forEach(el => {
135
- stopPolling(el)
136
- elementState.delete(el)
137
- })
138
- }
139
- }
140
-
141
- let started = false
142
- function start() {
143
- if (started || typeof document === "undefined") return
144
- started = true
145
- document.addEventListener("load", onLoad, true)
146
- document.addEventListener("error", onError, true)
147
- const observer = new MutationObserver(records => {
148
- for (const r of records) {
149
- r.addedNodes.forEach(processAdded)
150
- r.removedNodes.forEach(processRemoved)
151
- }
152
- })
153
- observer.observe(document.documentElement, { childList: true, subtree: true })
154
- document.querySelectorAll(SELECTOR).forEach(setupVideo)
155
- }
156
-
157
- function autostart() {
158
- if (typeof window === "undefined" || window.ActiveStorageAsyncVariants !== null) start()
159
- }
160
-
161
- if (typeof document !== "undefined") {
162
- if (document.readyState === "loading") {
163
- document.addEventListener("DOMContentLoaded", autostart)
164
- } else {
165
- setTimeout(autostart, 1)
166
- }
167
- }
168
-
169
- export { start }
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module ActiveStorage
4
- module AsyncVariants
5
- module RepresentationsRedirectControllerExtension
6
- ASYNC_HEADER = "X-Async-Variant-State"
7
- INTERCEPT_STATES = %w[pending processing failed].freeze
8
-
9
- def show
10
- state = @representation.async_state
11
- if INTERCEPT_STATES.include?(state)
12
- fallback = @representation.url(disposition: params[:disposition])
13
- if fallback.is_a?(String) && fallback.start_with?("/")
14
- path = Rails.public_path.join(fallback.delete_prefix("/"))
15
- if File.exist?(path)
16
- response.set_header(ASYNC_HEADER, state)
17
- response.set_header("Cache-Control", "no-store, private")
18
- return send_file path, disposition: "inline"
19
- end
20
- end
21
- end
22
- super
23
- end
24
- end
25
- end
26
- end