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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +2 -22
- data/Rakefile +9 -1
- data/app/assets/javascripts/retry.js +11 -0
- data/app/assets/stylesheets/retry.css +98 -0
- data/app/controllers/active_storage/async_variants/states_controller.rb +62 -0
- data/app/views/active_storage/async_variants/states/_failed.html.erb +43 -0
- data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
- data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
- data/app/views/active_storage/async_variants/states/_processing.html.erb +9 -0
- data/app/views/active_storage/async_variants/states/show.html.erb +10 -0
- data/config/routes.rb +9 -0
- data/features/retry_flow.feature +20 -0
- data/features/retry_visibility.feature +23 -0
- data/features/state_rendering.feature +22 -0
- data/features/step_definitions/dummy_steps.rb +116 -0
- data/features/support/env.rb +28 -0
- data/gemfiles/rails_7.2.gemfile +4 -0
- data/gemfiles/rails_8.0.gemfile +4 -0
- data/gemfiles/rails_8.1.gemfile +4 -0
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +38 -56
- data/lib/active_storage/async_variants/helper.rb +59 -0
- data/lib/active_storage/async_variants/preview_extension.rb +35 -32
- data/lib/active_storage/async_variants/variant_with_record_extension.rb +40 -30
- data/lib/active_storage/async_variants/version.rb +1 -1
- data/lib/active_storage/async_variants.rb +26 -15
- metadata +44 -4
- data/app/assets/javascripts/active_storage_async_variants.js +0 -169
- 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
|