active_storage-async_variants 0.3.1 → 0.4.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 +11 -0
- data/README.md +2 -22
- data/Rakefile +9 -1
- data/app/controllers/active_storage/async_variants/states_controller.rb +46 -0
- data/app/views/active_storage/async_variants/states/_failed.html.erb +4 -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 +10 -0
- data/app/views/active_storage/async_variants/states/show.html.erb +8 -0
- data/config/routes.rb +4 -0
- data/features/state_rendering.feature +21 -0
- data/features/step_definitions/dummy_steps.rb +116 -0
- data/features/support/env.rb +28 -0
- data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +40 -55
- 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 +11 -15
- metadata +26 -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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 003cbfe68ded6c238669546679e76b7d0c8e856842450181ceb8497a9e452361
|
|
4
|
+
data.tar.gz: 0b41dad0aa8a59e81e9b2e384d6d04e55ee479c0c35822d71e3a06a46b0c64cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e5eb0728baf9f4d9cfc591625f6035d362d9df192c2c64fb7cef4e397fcef9817a460dbfba24210f01d777360a1d2e73e4dabf7d00306f8c9685ff1df4641b0e
|
|
7
|
+
data.tar.gz: 4c87c749a9dad93f251fcddf5830996f53d7c054becabdd529ea89000eb75e5f2360b6332aea764fc615931b0c350a15bfe8610d4b5efa1561a22ffd3d966ee6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
## [0.4.0]
|
|
2
|
+
|
|
3
|
+
- **Breaking:** Replaced the polling-`<img>` architecture with a `<turbo-frame>` for unprocessed variants. `image_tag`/`video_tag` with `async: true` now emits a normal `<img>`/`<video>` when the variant is already processed, and a `<turbo-frame src="…">` otherwise. The frame hits a new gem-shipped endpoint (`GET /active_storage/async_variants/states/:signed_blob_id/:variation_key`) that renders one of `_processing`/`_failed`/`_processed` partials. Non-terminal renders include an inline `<script>` that schedules the frame's next reload, so the state polls itself until it terminates.
|
|
4
|
+
- New `app/views/active_storage/async_variants/states/` partial set — apps can override any partial by creating a same-named file in their own `app/views/active_storage/async_variants/states/`. The processing state defaults to a bare `<img>`/`<video>` pointing at the variant's URL, which the gem redirects to the app's configured `processing:` SVG.
|
|
5
|
+
- All CSS and JavaScript ship inline in the per-response state partials — no asset-pipeline footprint. Apps don't need to include any stylesheet or script.
|
|
6
|
+
- **Breaking:** `Variant#processed` and `Preview#processed` are now no-ops on bucket-backed services (they previously also lazy-enqueued `ProcessJob` for any record that wasn't already processed). Enqueue now happens only at attachment time (via `AttachmentExtension#transform_variants_later`). Pre-existing blobs that missed the auto-enqueue won't self-heal on first view; backfill via a rake task.
|
|
7
|
+
- New public `#enqueue!` method on both `Variant` and `Preview` with the same signature — no `respond_to?` dance. Creates a pending `VariantRecord` (RecordNotUnique guards dedupe) and dispatches `ProcessJob`. Replaces the previously private `enqueue_processing` / `enqueue_async_preview`.
|
|
8
|
+
- The named-variant lookup now walks one level back through `preview_image` attachments, so a request for a Variant of a video's extracted preview frame can recover the named-variant declaration from the parent record's source-video field. Fixes nil-URL 500s in dev where the cold Registry can't resolve a redirect-controller request.
|
|
9
|
+
- Added `turbo-rails >= 2.0` as a runtime dependency.
|
|
10
|
+
- Removed `RepresentationsRedirectControllerExtension`, the `[data-async-variant-*]` polling JS, the bundled `app/assets/stylesheets/active_storage_async_variants.css`, the engine's asset precompile initializer, and the `apply_async_data!` helper machinery. Consumers depending on those data attributes must migrate to targeting the new `.async-variant-state` classes or override the partials.
|
|
11
|
+
|
|
1
12
|
## [0.3.1]
|
|
2
13
|
|
|
3
14
|
- Bound the stored variant `error` to 16k chars at both write sites (the failed-status callback and `ProcessJob`'s rescue). An external transformer reporting a >64KB error payload was overflowing the `TEXT` column and 500ing the callback, leaving the variant stuck instead of marked failed.
|
data/README.md
CHANGED
|
@@ -97,29 +97,9 @@ ActiveStorage::AsyncVariants.cdn_host = "https://d1234abcd.cloudfront.net"
|
|
|
97
97
|
|
|
98
98
|
The resulting URL is `"#{cdn_host}/#{variant.key}"`.
|
|
99
99
|
|
|
100
|
-
###
|
|
100
|
+
### JavaScript
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
Using **importmap-rails**:
|
|
105
|
-
|
|
106
|
-
```ruby
|
|
107
|
-
# config/importmap.rb
|
|
108
|
-
pin "@active_storage/async_variants", to: "active_storage_async_variants.js", preload: true
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
```js
|
|
112
|
-
// app/javascript/application.js
|
|
113
|
-
import "@active_storage/async_variants"
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
Using **the asset pipeline** (sprockets/propshaft) with a classic `javascript_include_tag`:
|
|
117
|
-
|
|
118
|
-
```erb
|
|
119
|
-
<%= javascript_include_tag "active_storage_async_variants" %>
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
The module auto-starts on `DOMContentLoaded` and finds elements with `data-async-variant-state-value`. Opt out by setting `window.ActiveStorageAsyncVariants = null` before the script loads, then calling `start()` yourself from the exported module when ready.
|
|
102
|
+
No manual wiring is required. The async state partials are self-contained `<turbo-frame>`s. The only requirement is that the host app loads **Turbo** -- the gem depends on `turbo-rails`, which a default Rails app already includes.
|
|
123
103
|
|
|
124
104
|
## Writing a Transformer
|
|
125
105
|
|
data/Rakefile
CHANGED
|
@@ -5,4 +5,12 @@ require "rspec/core/rake_task"
|
|
|
5
5
|
|
|
6
6
|
RSpec::Core::RakeTask.new(:spec)
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
desc "Run browser-level acceptance tests (cucumber + cuprite against spec/dummy)"
|
|
9
|
+
task :cucumber do
|
|
10
|
+
sh "bundle exec cucumber"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "Run rspec and cucumber"
|
|
14
|
+
task all: [:spec, :cucumber]
|
|
15
|
+
|
|
16
|
+
task default: :all
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
class StatesController < ActiveStorage::AsyncVariants.parent_controller.constantize
|
|
6
|
+
# signed_blob_id + variation_key in URL act as CSRF: both require app's secret.
|
|
7
|
+
skip_forgery_protection
|
|
8
|
+
|
|
9
|
+
helper "turbo/frames"
|
|
10
|
+
helper ActiveStorage::AsyncVariants::Helper
|
|
11
|
+
|
|
12
|
+
layout false
|
|
13
|
+
|
|
14
|
+
before_action :set_variant
|
|
15
|
+
|
|
16
|
+
def show
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
helper_method :async_variant_kind, :async_variant_direct?, :async_variant_html_options
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def set_variant
|
|
24
|
+
blob = ActiveStorage::Blob.find_signed!(params[:signed_blob_id])
|
|
25
|
+
variation = ActiveStorage::Variation.decode(params[:variation_key])
|
|
26
|
+
@variant = blob.variant(variation)
|
|
27
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature
|
|
28
|
+
head :not_found
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def async_variant_kind
|
|
32
|
+
params[:kind].to_s == "video" ? :video : :image
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def async_variant_direct?
|
|
36
|
+
ActiveModel::Type::Boolean.new.cast(params[:direct])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def async_variant_html_options
|
|
40
|
+
params[:opts]
|
|
41
|
+
&.permit(*ActiveStorage::AsyncVariants::PASS_THROUGH_HTML_OPTIONS)
|
|
42
|
+
.to_h
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<%= render "active_storage/async_variants/states/processing", local_assigns %>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<div class="async-variant-state async-variant-processing">
|
|
2
|
+
<% method = kind == :video ? :video_tag : :image_tag %>
|
|
3
|
+
<%= send method, async_variant_representation_path(variant), **html_options.symbolize_keys %>
|
|
4
|
+
</div>
|
|
5
|
+
<%# Self-perpetuating poll: Turbo runs <script>s in frame swaps, so each
|
|
6
|
+
pending/processing response schedules its own next reload. Terminal
|
|
7
|
+
partials (_failed, _processed) emit no script -> chain stops. %>
|
|
8
|
+
<script>
|
|
9
|
+
setTimeout(() => document.getElementById("<%= async_variant_frame_id(variant) %>")?.reload(), 3000)
|
|
10
|
+
</script>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<%= turbo_frame_tag async_variant_frame_id(@variant) do %>
|
|
2
|
+
<%= render "active_storage/async_variants/states/#{@variant.async_state}", {
|
|
3
|
+
variant: @variant,
|
|
4
|
+
html_options: async_variant_html_options,
|
|
5
|
+
kind: async_variant_kind,
|
|
6
|
+
direct: async_variant_direct?,
|
|
7
|
+
} %>
|
|
8
|
+
<% end %>
|
data/config/routes.rb
CHANGED
|
@@ -4,4 +4,8 @@ Rails.application.routes.draw do
|
|
|
4
4
|
post "/active_storage/async_variants/callbacks/:token",
|
|
5
5
|
to: "active_storage/async_variants/callbacks#create",
|
|
6
6
|
as: :active_storage_async_variant_callback
|
|
7
|
+
|
|
8
|
+
get "/active_storage/async_variants/states/:signed_blob_id/:variation_key",
|
|
9
|
+
to: "active_storage/async_variants/states#show",
|
|
10
|
+
as: :async_variant_state
|
|
7
11
|
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Feature: image_tag with async: true emits the right markup per variant state
|
|
2
|
+
|
|
3
|
+
Scenario: A processed variant emits a plain <img> -- no turbo-frame, no chrome
|
|
4
|
+
Given a user with an attached avatar
|
|
5
|
+
And the avatar's :thumb_proc variant is in processed state
|
|
6
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
7
|
+
Then the page should NOT contain a turbo-frame
|
|
8
|
+
|
|
9
|
+
Scenario: An unprocessed variant emits a turbo-frame in processing state
|
|
10
|
+
Given a user with an attached avatar
|
|
11
|
+
And the avatar's :thumb_proc variant is in processing state
|
|
12
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
13
|
+
Then the page should contain a turbo-frame
|
|
14
|
+
And the frame should render the processing state
|
|
15
|
+
|
|
16
|
+
Scenario: A failed variant renders the failed partial inside the frame
|
|
17
|
+
Given a user with an attached avatar
|
|
18
|
+
And the avatar's :thumb_proc variant is in failed state with error "boom"
|
|
19
|
+
When I visit the avatar page for the :thumb_proc variant
|
|
20
|
+
Then the page should contain a turbo-frame
|
|
21
|
+
And the frame should render the failed state
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Given /^a user with an attached avatar$/ do
|
|
4
|
+
@user = User.create!
|
|
5
|
+
attach_avatar_to(@user)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
Given /^the avatar's :(\w+) variant is in (pending|processing|processed|failed) state(?: with error "([^"]+)")?$/ do |variant_name, state, error|
|
|
9
|
+
variant = @user.avatar.variant(variant_name.to_sym)
|
|
10
|
+
if state == "processed"
|
|
11
|
+
simulate_processed_variant(variant)
|
|
12
|
+
else
|
|
13
|
+
create_variant_record(variant, state: state, error: error)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
Given /^the retry affordance is visible to everyone$/ do
|
|
18
|
+
ActiveStorage::AsyncVariants.retry_visible_if { true }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Given /^the retry affordance is hidden$/ do
|
|
22
|
+
ActiveStorage::AsyncVariants.retry_visible_if { false }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Given /^the retry affordance requires a signed-in user$/ do
|
|
26
|
+
ActiveStorage::AsyncVariants.retry_visible_if { current_user.present? }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
When /^I sign in as the (admin|user)$/ do |_kind|
|
|
30
|
+
visit "/session/#{@user.id}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
When /^I visit the avatar page for the :(\w+) variant$/ do |variant_name|
|
|
34
|
+
visit "/avatars/#{@user.id}/#{variant_name}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Then /^the page should contain a turbo-frame$/ do
|
|
38
|
+
expect(page).to have_css("turbo-frame")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Then /^the page should NOT contain a turbo-frame$/ do
|
|
42
|
+
expect(page).to have_no_css("turbo-frame")
|
|
43
|
+
expect(page).to have_css("img")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Then /^the frame should render the (failed|processing|processed) state$/ do |state|
|
|
47
|
+
expect(page).to have_css("turbo-frame .async-variant-#{state}")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Then /^the retry affordance should be visible$/ do
|
|
51
|
+
expect(page).to have_css("async-variant-retry", visible: :all)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Then /^the retry affordance should NOT be visible$/ do
|
|
55
|
+
expect(page).to have_no_css("async-variant-retry", visible: :all)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
Then /^the page should not have navigated away$/ do
|
|
59
|
+
expect(page).to have_css("h1#page-marker")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Then /^the failure dialog should be open$/ do
|
|
63
|
+
expect(page).to have_css("async-variant-retry", visible: :all)
|
|
64
|
+
open = page.evaluate_script(<<~JS)
|
|
65
|
+
(() => {
|
|
66
|
+
const host = document.querySelector("async-variant-retry")
|
|
67
|
+
if (!host || !host.shadowRoot) return false
|
|
68
|
+
const dialog = host.shadowRoot.querySelector("dialog")
|
|
69
|
+
return dialog ? dialog.open : false
|
|
70
|
+
})()
|
|
71
|
+
JS
|
|
72
|
+
expect(open).to eq(true)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Then /^the failure dialog should contain "([^"]+)"$/ do |text|
|
|
76
|
+
contains = page.evaluate_script(<<~JS)
|
|
77
|
+
(() => {
|
|
78
|
+
const host = document.querySelector("async-variant-retry")
|
|
79
|
+
if (!host || !host.shadowRoot) return false
|
|
80
|
+
return host.shadowRoot.textContent.includes(#{text.inspect})
|
|
81
|
+
})()
|
|
82
|
+
JS
|
|
83
|
+
expect(contains).to eq(true)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Wait for Turbo to swap the frame and our shim to attach the shadow root.
|
|
87
|
+
def wait_for_shadow!
|
|
88
|
+
Timeout.timeout(5) do
|
|
89
|
+
loop do
|
|
90
|
+
attached = page.evaluate_script("!!document.querySelector('async-variant-retry')?.shadowRoot")
|
|
91
|
+
break if attached
|
|
92
|
+
sleep 0.05
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
When /^I click the retry opener$/ do
|
|
98
|
+
wait_for_shadow!
|
|
99
|
+
page.evaluate_script(<<~JS)
|
|
100
|
+
document.querySelector("async-variant-retry").shadowRoot.querySelector(".opener").click()
|
|
101
|
+
JS
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
When /^I click "Retry processing"$/ do
|
|
105
|
+
wait_for_shadow!
|
|
106
|
+
page.evaluate_script(<<~JS)
|
|
107
|
+
document.querySelector("async-variant-retry").shadowRoot.querySelector("footer button").click()
|
|
108
|
+
JS
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
When /^I close the failure dialog$/ do
|
|
112
|
+
wait_for_shadow!
|
|
113
|
+
page.evaluate_script(<<~JS)
|
|
114
|
+
document.querySelector("async-variant-retry").shadowRoot.querySelector("dialog > button[type=button]").click()
|
|
115
|
+
JS
|
|
116
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
ENV["RAILS_ENV"] ||= "test"
|
|
4
|
+
require File.expand_path("../../spec/dummy/config/environment", __dir__)
|
|
5
|
+
|
|
6
|
+
require "capybara"
|
|
7
|
+
require "capybara/cucumber"
|
|
8
|
+
require "capybara/cuprite"
|
|
9
|
+
require_relative "../../spec/support/schema"
|
|
10
|
+
|
|
11
|
+
DummySchema.load!
|
|
12
|
+
|
|
13
|
+
Capybara.register_driver(:cuprite) do |app|
|
|
14
|
+
Capybara::Cuprite::Driver.new(app, window_size: [1200, 900], timeout: 10, process_timeout: 20, js_errors: true)
|
|
15
|
+
end
|
|
16
|
+
Capybara.app = Rails.application
|
|
17
|
+
Capybara.javascript_driver = :cuprite
|
|
18
|
+
Capybara.default_driver = :cuprite
|
|
19
|
+
Capybara.server = :puma, { Silent: true }
|
|
20
|
+
|
|
21
|
+
ActionController::Base.allow_forgery_protection = false
|
|
22
|
+
|
|
23
|
+
# Test adapter just records enqueued jobs -- we don't want them to actually run
|
|
24
|
+
# during browser tests (they'd call real transformers and modify variant_records
|
|
25
|
+
# behind the test's back). The retry step seeds the new state explicitly.
|
|
26
|
+
ActiveJob::Base.queue_adapter = :test
|
|
27
|
+
|
|
28
|
+
Before { DummySchema.cleanup! }
|
|
@@ -2,76 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
|
+
# Prepended onto ActionView::Helpers::AssetTagHelper. Adds `async:` and
|
|
6
|
+
# `direct:` options to image_tag/video_tag; routes unprocessed variants
|
|
7
|
+
# through a <turbo-frame> whose body lives in StatesController#show.
|
|
5
8
|
module AssetTagHelperExtension
|
|
9
|
+
include ActiveStorage::AsyncVariants::Helper
|
|
10
|
+
|
|
6
11
|
def image_tag(source, options = {})
|
|
7
|
-
options
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return super if !async && !direct
|
|
11
|
-
variant = AssetTagHelperExtension.coerce_variant!(source)
|
|
12
|
-
src = AssetTagHelperExtension.resolve_src(variant, direct: direct)
|
|
13
|
-
AssetTagHelperExtension.apply_async_data!(options, variant: variant, direct: direct) if async
|
|
14
|
-
super(src, options)
|
|
12
|
+
async_variant_tag(:image, [source], options) do |urls, opts|
|
|
13
|
+
super(urls.first, opts)
|
|
14
|
+
end
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def video_tag(*sources)
|
|
18
|
-
options = sources.extract_options
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
variant = AssetTagHelperExtension.coerce_variant!(sources.first)
|
|
23
|
-
sources[0] = AssetTagHelperExtension.resolve_src(variant, direct: direct)
|
|
24
|
-
AssetTagHelperExtension.apply_async_data!(options, variant: variant, direct: direct) if async
|
|
25
|
-
super(*sources, options)
|
|
18
|
+
options = sources.extract_options!
|
|
19
|
+
async_variant_tag(:video, sources, options) do |urls, opts|
|
|
20
|
+
super(*urls, opts)
|
|
21
|
+
end
|
|
26
22
|
end
|
|
27
23
|
|
|
28
|
-
|
|
29
|
-
def coerce_variant!(source)
|
|
30
|
-
unless source.is_a?(ActiveStorage::VariantWithRecord) || source.is_a?(ActiveStorage::Preview)
|
|
31
|
-
raise ArgumentError, "image_tag/video_tag with async:/direct: requires an ActiveStorage::VariantWithRecord or Preview, got #{source.class}"
|
|
32
|
-
end
|
|
33
|
-
source
|
|
34
|
-
end
|
|
24
|
+
private
|
|
35
25
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# never-touched variant doesn't sit pending forever. Gated on
|
|
42
|
-
# bucket-backed services -- on Disk/Test the gem defers to
|
|
43
|
-
# vanilla ActiveStorage, and #processed would run a synchronous
|
|
44
|
-
# transform (e.g. vips on an mp4) that the external transformer
|
|
45
|
-
# is supposed to handle.
|
|
46
|
-
variant.processed if variant.blob.bucket_backed?
|
|
47
|
-
polymorphic_url(variant)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
26
|
+
def async_variant_tag(kind, sources, options)
|
|
27
|
+
options = options.symbolize_keys
|
|
28
|
+
async = options.delete(:async)
|
|
29
|
+
direct = options.delete(:direct)
|
|
30
|
+
return yield(sources, options) if !async && !direct
|
|
50
31
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
controllers = data[:controller].to_s.split
|
|
54
|
-
controllers << "async-variant" unless controllers.include?("async-variant")
|
|
55
|
-
data[:controller] = controllers.join(" ")
|
|
56
|
-
data[:async_variant_src_value] = polymorphic_url(variant)
|
|
57
|
-
data[:async_variant_state_value] = variant.async_state
|
|
58
|
-
data[:async_variant_direct_value] = direct_url(variant) if direct && variant.async_state == "processed"
|
|
59
|
-
options[:data] = data
|
|
60
|
-
end
|
|
32
|
+
variant, *rest = sources
|
|
33
|
+
assert_async_variant!(variant)
|
|
61
34
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
variant.image.url
|
|
35
|
+
if async && !async_variant_processed_inline?(variant)
|
|
36
|
+
async_variant_turbo_frame(variant, kind:, direct:, html_options: options) do
|
|
37
|
+
# populate TurboFrame with a placeholder image/video, so there's valid markup right away
|
|
38
|
+
yield [async_variant_representation_path(variant), *rest], options.except(:data)
|
|
67
39
|
end
|
|
40
|
+
else
|
|
41
|
+
yield [async_variant_resolved_src(variant, direct:), *rest], options
|
|
68
42
|
end
|
|
43
|
+
end
|
|
69
44
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
45
|
+
def assert_async_variant!(source)
|
|
46
|
+
unless source.is_a?(ActiveStorage::VariantWithRecord) || source.is_a?(ActiveStorage::Preview)
|
|
47
|
+
raise ArgumentError, "image_tag/video_tag with async:/direct: requires an ActiveStorage::VariantWithRecord or Preview, got #{source.class}"
|
|
73
48
|
end
|
|
74
49
|
end
|
|
50
|
+
|
|
51
|
+
def async_variant_turbo_frame(variant, kind:, direct:, html_options:, &block)
|
|
52
|
+
content_tag(
|
|
53
|
+
:"turbo-frame",
|
|
54
|
+
id: async_variant_frame_id(variant),
|
|
55
|
+
src: async_variant_frame_src(variant, kind: kind, direct: direct, html_options: html_options),
|
|
56
|
+
refresh: "morph",
|
|
57
|
+
&block
|
|
58
|
+
)
|
|
59
|
+
end
|
|
75
60
|
end
|
|
76
61
|
end
|
|
77
62
|
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AsyncVariants
|
|
5
|
+
# View/controller helper methods. Included in the AssetTagHelperExtension
|
|
6
|
+
# (so image_tag/video_tag can use them) and added to StatesController via
|
|
7
|
+
# `helper ActiveStorage::AsyncVariants::Helper` (so the state partials can
|
|
8
|
+
# use them).
|
|
9
|
+
module Helper
|
|
10
|
+
# In test with non-bucket-backed services, the gem defers to vanilla
|
|
11
|
+
# ActiveStorage (synchronous vips transform) -- inline rendering keeps
|
|
12
|
+
# those environments simple. Otherwise, only inline a normal <img> when
|
|
13
|
+
# the variant has reached the processed terminal state.
|
|
14
|
+
def async_variant_processed_inline?(variant)
|
|
15
|
+
!variant.blob.bucket_backed? || variant.async_state == "processed"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def async_variant_resolved_src(variant, direct:)
|
|
19
|
+
if direct && variant.async_state == "processed"
|
|
20
|
+
async_variant_direct_url(variant)
|
|
21
|
+
else
|
|
22
|
+
variant.processed if variant.blob.bucket_backed?
|
|
23
|
+
async_variant_representation_path(variant)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def async_variant_frame_id(variant)
|
|
28
|
+
digest = variant.variation.digest.gsub(/[^a-zA-Z0-9_-]/, "")
|
|
29
|
+
"async-variant-#{variant.blob.id}-#{digest}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def async_variant_frame_src(variant, kind:, direct:, html_options: {})
|
|
33
|
+
async_variant_state_path(
|
|
34
|
+
signed_blob_id: variant.blob.signed_id,
|
|
35
|
+
variation_key: variant.variation.key,
|
|
36
|
+
kind:,
|
|
37
|
+
direct:,
|
|
38
|
+
opts: html_options.slice(*PASS_THROUGH_HTML_OPTIONS),
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def async_variant_direct_url(variant)
|
|
43
|
+
if cdn = ActiveStorage::AsyncVariants.cdn_host
|
|
44
|
+
"#{cdn}/#{variant.key}"
|
|
45
|
+
else
|
|
46
|
+
variant.image.url
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def async_variant_representation_path(variant)
|
|
51
|
+
Rails.application.routes.url_helpers.rails_blob_representation_path(
|
|
52
|
+
variant.blob.signed_id,
|
|
53
|
+
variant.variation.key,
|
|
54
|
+
variant.blob.filename.to_s,
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -3,18 +3,27 @@
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
module PreviewExtension
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# the gem's earlier two-path design (one writing to preview_image's
|
|
10
|
-
# variant_records, one to the original blob's).
|
|
6
|
+
# Block vanilla ActiveStorage's synchronous preview transform on
|
|
7
|
+
# async-backed services; rely on the auto-enqueue (AttachmentExtension)
|
|
8
|
+
# path -- and the public #enqueue! -- to dispatch ProcessJob.
|
|
11
9
|
def processed
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
10
|
+
async_preview? ? self : super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def enqueue!
|
|
14
|
+
if result = find_named_async_variant
|
|
15
|
+
attachment, variant_name, _ = result
|
|
16
|
+
|
|
17
|
+
blob.variant_records.create!(
|
|
18
|
+
variation_digest: variation.digest,
|
|
19
|
+
state: "pending",
|
|
20
|
+
)
|
|
21
|
+
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
22
|
+
attachment.record, attachment.name, variant_name.to_s,
|
|
23
|
+
)
|
|
17
24
|
end
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
# another caller (or a leftover record) wins; their job handles it
|
|
18
27
|
end
|
|
19
28
|
|
|
20
29
|
def processed?
|
|
@@ -52,14 +61,29 @@ module ActiveStorage
|
|
|
52
61
|
# Variations rebuilt from the redirect URL only carry transformations --
|
|
53
62
|
# :transformer / :processing / :failed are stripped at Variation#initialize
|
|
54
63
|
# and not embedded in the URL key. Recover them via the digest-keyed
|
|
55
|
-
# registry that VariationExtension warms on every view-side variant call
|
|
64
|
+
# registry that VariationExtension warms on every view-side variant call,
|
|
65
|
+
# or fall back to scanning attached named variants when the registry is
|
|
66
|
+
# cold (autoloader hasn't touched the consumer model yet).
|
|
56
67
|
def resolved_async_options
|
|
57
68
|
@resolved_async_options ||=
|
|
58
69
|
variation.async_options.presence ||
|
|
59
70
|
ActiveStorage::AsyncVariants::Registry[variation.digest] ||
|
|
71
|
+
find_named_async_variant&.dig(2) ||
|
|
60
72
|
{}
|
|
61
73
|
end
|
|
62
74
|
|
|
75
|
+
def find_named_async_variant
|
|
76
|
+
target = variation.transformations.to_json
|
|
77
|
+
blob.attachments.each do |attachment|
|
|
78
|
+
attachment.send(:named_variants).each do |name, _|
|
|
79
|
+
candidate = attachment.variant(name.to_sym)
|
|
80
|
+
next unless candidate.variation.transformations.to_json == target
|
|
81
|
+
return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:transformer].present?
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
63
87
|
def preview_variant_processed?
|
|
64
88
|
find_preview_variant_record&.state == "processed"
|
|
65
89
|
end
|
|
@@ -71,27 +95,6 @@ module ActiveStorage
|
|
|
71
95
|
blob.variant_records.find_by(variation_digest: variation.digest)
|
|
72
96
|
end
|
|
73
97
|
|
|
74
|
-
# Delegate to the named-variant VariantWithRecord so we go through the
|
|
75
|
-
# exact same enqueue_processing + ProcessJob machinery as direct
|
|
76
|
-
# variant calls. Skips silently if no matching named variant exists,
|
|
77
|
-
# which can happen for raw transformations that don't correspond to
|
|
78
|
-
# any declared async variant. Also skipped on non-bucket services,
|
|
79
|
-
# where the gem defers to vanilla ActiveStorage and dispatching here
|
|
80
|
-
# would synchronously transform via vips (broken for video blobs).
|
|
81
|
-
def enqueue_async_preview
|
|
82
|
-
return unless blob.bucket_backed?
|
|
83
|
-
target = variation.transformations.to_json
|
|
84
|
-
blob.attachments.each do |attachment|
|
|
85
|
-
attachment.send(:named_variants).each do |name, _|
|
|
86
|
-
candidate = attachment.variant(name.to_sym)
|
|
87
|
-
next unless candidate.variation.transformations.to_json == target
|
|
88
|
-
next unless candidate.variation.async_options[:processing].present?
|
|
89
|
-
candidate.processed
|
|
90
|
-
return
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
|
|
95
98
|
def fallback_preview_url(...)
|
|
96
99
|
case active_fallback
|
|
97
100
|
when :original then blob.url(...)
|
|
@@ -3,13 +3,27 @@
|
|
|
3
3
|
module ActiveStorage
|
|
4
4
|
module AsyncVariants
|
|
5
5
|
module VariantWithRecordExtension
|
|
6
|
+
# Block vanilla ActiveStorage's synchronous transform on bucket-backed
|
|
7
|
+
# services; rely on the auto-enqueue (AttachmentExtension) path -- and the
|
|
8
|
+
# public #enqueue! -- to dispatch ProcessJob.
|
|
6
9
|
def processed
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
blob.bucket_backed? ? self : super
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def enqueue!
|
|
14
|
+
if result = find_named_async_variant
|
|
15
|
+
attachment, variant_name, _ = result
|
|
16
|
+
|
|
17
|
+
blob.variant_records.create!(
|
|
18
|
+
variation_digest: variation.digest,
|
|
19
|
+
state: "pending",
|
|
20
|
+
)
|
|
21
|
+
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
22
|
+
attachment.record, attachment.name, variant_name.to_s,
|
|
23
|
+
)
|
|
12
24
|
end
|
|
25
|
+
rescue ActiveRecord::RecordNotUnique
|
|
26
|
+
# another caller (or a leftover record) wins; their job handles it
|
|
13
27
|
end
|
|
14
28
|
|
|
15
29
|
def url(...)
|
|
@@ -62,6 +76,7 @@ module ActiveStorage
|
|
|
62
76
|
@resolved_async_options ||=
|
|
63
77
|
variation.async_options.presence ||
|
|
64
78
|
ActiveStorage::AsyncVariants::Registry[variation.digest] ||
|
|
79
|
+
find_named_async_variant&.dig(2) ||
|
|
65
80
|
{}
|
|
66
81
|
end
|
|
67
82
|
|
|
@@ -73,33 +88,29 @@ module ActiveStorage
|
|
|
73
88
|
end
|
|
74
89
|
end
|
|
75
90
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
state: "pending",
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
ActiveStorage::AsyncVariants::ProcessJob.perform_later(
|
|
89
|
-
attachment.record, attachment.name, variant_name.to_s,
|
|
90
|
-
)
|
|
91
|
-
rescue ActiveRecord::RecordNotUnique
|
|
92
|
-
# another caller won the race; their job will handle processing
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
# Cold-path scan: only used by enqueue_processing, which needs the
|
|
96
|
-
# (attachment.record, attachment.name, variant_name) tuple to dispatch
|
|
97
|
-
# ProcessJob -- more than the digest registry stores. Hot-path URL
|
|
98
|
-
# resolution goes through Registry, not this method.
|
|
91
|
+
# Cold-path scan: used by enqueue! (which needs the attachment +
|
|
92
|
+
# variant_name to dispatch ProcessJob) and by resolved_async_options as
|
|
93
|
+
# a fallback when the Registry is cold (e.g. in dev, when a
|
|
94
|
+
# RepresentationsRedirectController request hits a worker that hasn't
|
|
95
|
+
# autoloaded the consumer model yet).
|
|
96
|
+
#
|
|
97
|
+
# Walks one level through preview_image attachments so a request for
|
|
98
|
+
# a Variant of a video's extracted preview frame can still find the
|
|
99
|
+
# named variant declared on the parent record's source-video field.
|
|
99
100
|
def find_named_async_variant
|
|
100
101
|
target = variation.transformations.to_json
|
|
102
|
+
scan_for_named_variant(blob, target)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def scan_for_named_variant(blob_to_scan, target, depth: 0)
|
|
106
|
+
blob_to_scan.attachments.each do |attachment|
|
|
107
|
+
if attachment.name == "preview_image" && attachment.record_type == "ActiveStorage::Blob" && depth < 1
|
|
108
|
+
source = ActiveStorage::Blob.find_by(id: attachment.record_id)
|
|
109
|
+
result = source && scan_for_named_variant(source, target, depth: depth + 1)
|
|
110
|
+
return result if result
|
|
111
|
+
next
|
|
112
|
+
end
|
|
101
113
|
|
|
102
|
-
blob.attachments.each do |attachment|
|
|
103
114
|
attachment.send(:named_variants).each do |name, _|
|
|
104
115
|
candidate = attachment.variant(name.to_sym)
|
|
105
116
|
if candidate.variation.transformations.to_json == target
|
|
@@ -107,7 +118,6 @@ module ActiveStorage
|
|
|
107
118
|
end
|
|
108
119
|
end
|
|
109
120
|
end
|
|
110
|
-
|
|
111
121
|
nil
|
|
112
122
|
end
|
|
113
123
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "turbo-rails"
|
|
3
4
|
require_relative "async_variants/version"
|
|
5
|
+
require_relative "async_variants/helper"
|
|
4
6
|
require_relative "async_variants/transformer"
|
|
5
7
|
require_relative "async_variants/registry"
|
|
6
8
|
require_relative "async_variants/blob_extension"
|
|
@@ -11,22 +13,22 @@ require_relative "async_variants/preview_extension"
|
|
|
11
13
|
require_relative "async_variants/attachment_extension"
|
|
12
14
|
require_relative "async_variants/reflection_extension"
|
|
13
15
|
require_relative "async_variants/process_job"
|
|
14
|
-
require_relative "async_variants/representations_redirect_controller_extension"
|
|
15
16
|
require_relative "async_variants/asset_tag_helper_extension"
|
|
16
17
|
|
|
17
18
|
module ActiveStorage
|
|
18
19
|
module AsyncVariants
|
|
20
|
+
# HTML attributes round-tripped through the state-endpoint URL so the
|
|
21
|
+
# eventual processed-state render can apply them to the inner <img>/<video>.
|
|
22
|
+
PASS_THROUGH_HTML_OPTIONS = %i[alt width height controls autoplay preload].freeze
|
|
23
|
+
|
|
19
24
|
mattr_accessor :cdn_host
|
|
20
25
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
app.config.assets.precompile += %w[active_storage_async_variants.js]
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
# :nocov:
|
|
26
|
+
# Lets the host app plug its auth chain (and thus `current_user`) into the
|
|
27
|
+
# gem's StatesController. Set to a string so resolution is deferred until
|
|
28
|
+
# the host's class is autoloadable. Defaults to ActionController::Base.
|
|
29
|
+
mattr_accessor :parent_controller, default: "ActionController::Base"
|
|
29
30
|
|
|
31
|
+
class Engine < ::Rails::Engine
|
|
30
32
|
# Prepend the core model/reflection extensions before eager_load runs
|
|
31
33
|
# so that models' has_X_attached blocks (and the Variation.wrap calls
|
|
32
34
|
# they trigger via reflection.variant) go through our hooks. The
|
|
@@ -46,9 +48,6 @@ module ActiveStorage
|
|
|
46
48
|
# the first one loads.
|
|
47
49
|
ActiveStorage::AsyncVariants.prepend_model_extensions!
|
|
48
50
|
|
|
49
|
-
ActiveStorage::Representations::RedirectController.prepend(
|
|
50
|
-
ActiveStorage::AsyncVariants::RepresentationsRedirectControllerExtension
|
|
51
|
-
)
|
|
52
51
|
ActionView::Helpers::AssetTagHelper.prepend(
|
|
53
52
|
ActiveStorage::AsyncVariants::AssetTagHelperExtension
|
|
54
53
|
)
|
|
@@ -56,9 +55,6 @@ module ActiveStorage
|
|
|
56
55
|
end
|
|
57
56
|
|
|
58
57
|
def self.prepend_model_extensions!
|
|
59
|
-
return if @model_extensions_prepended
|
|
60
|
-
@model_extensions_prepended = true
|
|
61
|
-
|
|
62
58
|
require "active_storage/reflection"
|
|
63
59
|
ActiveStorage::Reflection::HasAttachedReflection.prepend(
|
|
64
60
|
ActiveStorage::AsyncVariants::ReflectionExtension
|
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: active_storage-async_variants
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Micah Geisel
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-
|
|
10
|
+
date: 2026-06-06 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: activestorage
|
|
@@ -23,6 +23,20 @@ dependencies:
|
|
|
23
23
|
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '7.2'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: turbo-rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
26
40
|
email:
|
|
27
41
|
- micah@botandrose.com
|
|
28
42
|
executables: []
|
|
@@ -38,9 +52,17 @@ files:
|
|
|
38
52
|
- LICENSE.txt
|
|
39
53
|
- README.md
|
|
40
54
|
- Rakefile
|
|
41
|
-
- app/assets/javascripts/active_storage_async_variants.js
|
|
42
55
|
- app/controllers/active_storage/async_variants/callbacks_controller.rb
|
|
56
|
+
- app/controllers/active_storage/async_variants/states_controller.rb
|
|
57
|
+
- app/views/active_storage/async_variants/states/_failed.html.erb
|
|
58
|
+
- app/views/active_storage/async_variants/states/_pending.html.erb
|
|
59
|
+
- app/views/active_storage/async_variants/states/_processed.html.erb
|
|
60
|
+
- app/views/active_storage/async_variants/states/_processing.html.erb
|
|
61
|
+
- app/views/active_storage/async_variants/states/show.html.erb
|
|
43
62
|
- config/routes.rb
|
|
63
|
+
- features/state_rendering.feature
|
|
64
|
+
- features/step_definitions/dummy_steps.rb
|
|
65
|
+
- features/support/env.rb
|
|
44
66
|
- gemfiles/rails_7.2.gemfile
|
|
45
67
|
- gemfiles/rails_8.0.gemfile
|
|
46
68
|
- gemfiles/rails_8.1.gemfile
|
|
@@ -48,11 +70,11 @@ files:
|
|
|
48
70
|
- lib/active_storage/async_variants/asset_tag_helper_extension.rb
|
|
49
71
|
- lib/active_storage/async_variants/attachment_extension.rb
|
|
50
72
|
- lib/active_storage/async_variants/blob_extension.rb
|
|
73
|
+
- lib/active_storage/async_variants/helper.rb
|
|
51
74
|
- lib/active_storage/async_variants/preview_extension.rb
|
|
52
75
|
- lib/active_storage/async_variants/process_job.rb
|
|
53
76
|
- lib/active_storage/async_variants/reflection_extension.rb
|
|
54
77
|
- lib/active_storage/async_variants/registry.rb
|
|
55
|
-
- lib/active_storage/async_variants/representations_redirect_controller_extension.rb
|
|
56
78
|
- lib/active_storage/async_variants/transformer.rb
|
|
57
79
|
- lib/active_storage/async_variants/variant_record_extension.rb
|
|
58
80
|
- lib/active_storage/async_variants/variant_with_record_extension.rb
|
|
@@ -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
|