active_storage-async_variants 0.1.0 → 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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +0 -3
  3. data/CHANGELOG.md +21 -1
  4. data/CLAUDE.md +4 -4
  5. data/README.md +67 -27
  6. data/Rakefile +9 -1
  7. data/app/controllers/active_storage/async_variants/callbacks_controller.rb +34 -1
  8. data/app/controllers/active_storage/async_variants/states_controller.rb +46 -0
  9. data/app/views/active_storage/async_variants/states/_failed.html.erb +4 -0
  10. data/app/views/active_storage/async_variants/states/_pending.html.erb +1 -0
  11. data/app/views/active_storage/async_variants/states/_processed.html.erb +5 -0
  12. data/app/views/active_storage/async_variants/states/_processing.html.erb +10 -0
  13. data/app/views/active_storage/async_variants/states/show.html.erb +8 -0
  14. data/config/routes.rb +4 -0
  15. data/features/state_rendering.feature +21 -0
  16. data/features/step_definitions/dummy_steps.rb +116 -0
  17. data/features/support/env.rb +28 -0
  18. data/gemfiles/rails_7.2.gemfile +1 -0
  19. data/gemfiles/rails_8.0.gemfile +1 -0
  20. data/gemfiles/rails_8.1.gemfile +1 -0
  21. data/lib/active_storage/async_variants/asset_tag_helper_extension.rb +62 -0
  22. data/lib/active_storage/async_variants/attachment_extension.rb +3 -1
  23. data/lib/active_storage/async_variants/blob_extension.rb +15 -0
  24. data/lib/active_storage/async_variants/helper.rb +59 -0
  25. data/lib/active_storage/async_variants/preview_extension.rb +120 -0
  26. data/lib/active_storage/async_variants/process_job.rb +6 -7
  27. data/lib/active_storage/async_variants/reflection_extension.rb +35 -0
  28. data/lib/active_storage/async_variants/registry.rb +38 -0
  29. data/lib/active_storage/async_variants/variant_record_extension.rb +29 -0
  30. data/lib/active_storage/async_variants/variant_with_record_extension.rb +88 -19
  31. data/lib/active_storage/async_variants/variation_extension.rb +2 -1
  32. data/lib/active_storage/async_variants/version.rb +1 -1
  33. data/lib/active_storage/async_variants.rb +66 -9
  34. metadata +32 -5
  35. data/gemfiles/rails_7.2.gemfile.lock +0 -269
  36. data/gemfiles/rails_8.0.gemfile.lock +0 -266
  37. data/gemfiles/rails_8.1.gemfile.lock +0 -269
@@ -11,8 +11,10 @@ module ActiveStorage
11
11
  end
12
12
 
13
13
  def enqueue_async_variant_jobs
14
+ return unless blob.bucket_backed?
15
+
14
16
  named_variants.each do |name, named_variant|
15
- next unless named_variant.transformations.key?(:fallback)
17
+ next unless named_variant.transformations.key?(:processing)
16
18
 
17
19
  ActiveStorage::AsyncVariants::ProcessJob.perform_later(
18
20
  record, self.name, name.to_s,
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ module BlobExtension
6
+ # True when the blob's service is a remote/cloud service that the async
7
+ # processing workers (Crucible, etc.) can reach via presigned URLs. The
8
+ # Disk and Test services return false; the gem then defers to vanilla
9
+ # ActiveStorage rather than trying to enqueue jobs or serve fallbacks.
10
+ def bucket_backed?
11
+ service.respond_to?(:bucket)
12
+ end
13
+ end
14
+ end
15
+ 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
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ module PreviewExtension
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.
9
+ def processed
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
+ )
24
+ end
25
+ rescue ActiveRecord::RecordNotUnique
26
+ # another caller (or a leftover record) wins; their job handles it
27
+ end
28
+
29
+ def processed?
30
+ async_preview? ? preview_variant_processed? : super
31
+ end
32
+
33
+ def url(...)
34
+ if async_preview?
35
+ preview_variant_processed? ? find_preview_variant_record.image.url(...) : fallback_preview_url(...)
36
+ else
37
+ super
38
+ end
39
+ end
40
+
41
+ def key
42
+ if async_preview?
43
+ raise ActiveStorage::Preview::UnprocessedError unless preview_variant_processed?
44
+ find_preview_variant_record.image.blob.key
45
+ else
46
+ super
47
+ end
48
+ end
49
+
50
+ def async_state
51
+ return nil unless async_preview?
52
+ find_preview_variant_record&.state || "pending"
53
+ end
54
+
55
+ private
56
+
57
+ def async_preview?
58
+ resolved_async_options[:transformer].present?
59
+ end
60
+
61
+ # Variations rebuilt from the redirect URL only carry transformations --
62
+ # :transformer / :processing / :failed are stripped at Variation#initialize
63
+ # and not embedded in the URL key. Recover them via the digest-keyed
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).
67
+ def resolved_async_options
68
+ @resolved_async_options ||=
69
+ variation.async_options.presence ||
70
+ ActiveStorage::AsyncVariants::Registry[variation.digest] ||
71
+ find_named_async_variant&.dig(2) ||
72
+ {}
73
+ end
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
+
87
+ def preview_variant_processed?
88
+ find_preview_variant_record&.state == "processed"
89
+ end
90
+
91
+ # ProcessJob stores variant_records on the source blob (i.e. @variant.blob,
92
+ # which for a named variant declared on a previewable attachment is the
93
+ # original blob -- not preview_image.blob). Read from the same place.
94
+ def find_preview_variant_record
95
+ blob.variant_records.find_by(variation_digest: variation.digest)
96
+ end
97
+
98
+ def fallback_preview_url(...)
99
+ case active_fallback
100
+ when :original then blob.url(...)
101
+ when :blank then nil
102
+ when Proc then active_fallback.call(blob)
103
+ when String then active_fallback
104
+ end
105
+ end
106
+
107
+ def active_fallback
108
+ if failed?
109
+ resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
110
+ else
111
+ resolved_async_options[:processing]
112
+ end
113
+ end
114
+
115
+ def failed?
116
+ find_preview_variant_record&.state == "failed"
117
+ end
118
+ end
119
+ end
120
+ end
@@ -3,6 +3,10 @@
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
5
  class ProcessJob < ActiveJob::Base
6
+ retry_on StandardError, wait: :polynomially_longer, attempts: 3 do |job, error|
7
+ job.logger.error "AsyncVariants: permanently failed: #{error.message}"
8
+ end
9
+
6
10
  def perform(record, attachment_name, variant_name)
7
11
  attachment = record.public_send(attachment_name)
8
12
  @variant = attachment.variant(variant_name.to_sym)
@@ -27,15 +31,10 @@ module ActiveStorage
27
31
  rescue => e
28
32
  @variant_record&.update!(
29
33
  state: "failed",
30
- error: e.message,
34
+ error: e.message.to_s.truncate(16_000),
31
35
  attempts: (@variant_record&.attempts || 0) + 1,
32
36
  )
33
- max_retries = @async_options&.fetch(:max_retries, 3) || 3
34
- if @variant_record.nil? || @variant_record.attempts < max_retries
35
- raise
36
- else
37
- logger.error "AsyncVariants: permanently failed after #{@variant_record.attempts} attempts: #{e.message}"
38
- end
37
+ raise
39
38
  end
40
39
 
41
40
  private
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ # Eagerly warm the digest-keyed Registry when a named variant is declared
6
+ # via has_X_attached's block:
7
+ #
8
+ # has_one_attached :avatar do |attachable|
9
+ # attachable.variant :thumb, resize_to_limit: [150, 150], format: "webp",
10
+ # transformer: Crucible, processing: "/spinner.svg"
11
+ # end
12
+ #
13
+ # Without this, the Registry only warms when view-side code calls
14
+ # `attachment.variant(:thumb)` -- so a Puma worker that has never rendered
15
+ # a view for the variant misses on URL-reconstructed lookups.
16
+ #
17
+ # Caveat: registration uses the declared transformations. Rails applies a
18
+ # blob-dependent default `format:` at runtime via default_to. Variants that
19
+ # specify `format:` explicitly register the same digest the URL carries
20
+ # (cold-resolvable). Variants that rely on the format default still warm
21
+ # lazily on first view-side call against a blob of that content type.
22
+ module ReflectionExtension
23
+ def variant(name, transformations)
24
+ super
25
+ return unless transformations.is_a?(Hash)
26
+ # Mirror Blob#variant at runtime: wrap and default_to. The default_to
27
+ # call reorders hash keys (default key first) so the registered digest
28
+ # matches the URL-side digest for variants that declare an explicit
29
+ # `format:`. Variants without an explicit `format:` still warm lazily
30
+ # on first view-side call against a blob of that content type.
31
+ ActiveStorage::Variation.wrap(transformations).default_to(format: :png)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module ActiveStorage
6
+ module AsyncVariants
7
+ # Process-wide lookup of async_options keyed by Variation#digest.
8
+ #
9
+ # Populated lazily by VariationExtension#initialize whenever a Variation is
10
+ # constructed with :transformer in its async_options -- which happens on
11
+ # every view-side `attachment.variant(:name)` call. The redirect controller
12
+ # then resolves async_options by digest without scanning blob.attachments
13
+ # for a transformations-match.
14
+ #
15
+ # Cold-worker behavior: if a worker receives a redirect-URL request before
16
+ # any view-side call has registered the variant's digest, the lookup misses
17
+ # and resolution falls through to standard Rails (no leak, no spinner).
18
+ # Self-warms on subsequent view requests.
19
+ class Registry
20
+ MONITOR = Monitor.new
21
+ STORE = {}
22
+
23
+ class << self
24
+ def register(digest, async_options)
25
+ MONITOR.synchronize { STORE[digest] = async_options }
26
+ end
27
+
28
+ def [](digest)
29
+ MONITOR.synchronize { STORE[digest] }
30
+ end
31
+
32
+ def clear
33
+ MONITOR.synchronize { STORE.clear }
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveStorage
4
+ module AsyncVariants
5
+ module VariantRecordExtension
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ after_update_commit :touch_attached_records, if: :reached_terminal_state?
10
+ end
11
+
12
+ private
13
+
14
+ # processed and failed are terminal states: cache fragments built when
15
+ # state was pending/processing need to be invalidated so the next
16
+ # render sees the new state (and serves the failed: fallback URL,
17
+ # swaps src to the direct CDN URL, etc.).
18
+ def reached_terminal_state?
19
+ saved_change_to_state? && %w[processed failed].include?(state)
20
+ end
21
+
22
+ def touch_attached_records
23
+ blob.attachments.includes(:record).each do |attachment|
24
+ attachment.record&.touch
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -3,16 +3,50 @@
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.
9
+ def processed
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
+ )
24
+ end
25
+ rescue ActiveRecord::RecordNotUnique
26
+ # another caller (or a leftover record) wins; their job handles it
27
+ end
28
+
6
29
  def url(...)
7
- if processed?
8
- super
30
+ if blob.bucket_backed? && !processed?
31
+ fallback = active_fallback
32
+ case fallback
33
+ when :original then blob.url(...)
34
+ when :blank then nil
35
+ when Proc then fallback.call(blob)
36
+ when String then fallback
37
+ else super
38
+ end
9
39
  else
10
- fallback_url(...)
40
+ super
11
41
  end
12
42
  end
13
43
 
14
- def ready?
15
- async_record&.state == "processed"
44
+ def processed?
45
+ if blob.bucket_backed?
46
+ async_record&.state == "processed"
47
+ else
48
+ super
49
+ end
16
50
  end
17
51
 
18
52
  def processing?
@@ -31,29 +65,64 @@ module ActiveStorage
31
65
  async_record&.error
32
66
  end
33
67
 
68
+ def async_state
69
+ return nil unless blob.bucket_backed?
70
+ async_record&.state || "pending"
71
+ end
72
+
34
73
  private
35
74
 
36
- def processed?
37
- async? ? ready? : super
75
+ def resolved_async_options
76
+ @resolved_async_options ||=
77
+ variation.async_options.presence ||
78
+ ActiveStorage::AsyncVariants::Registry[variation.digest] ||
79
+ find_named_async_variant&.dig(2) ||
80
+ {}
38
81
  end
39
82
 
40
- def async?
41
- variation.async_options[:fallback].present?
83
+ def active_fallback
84
+ if failed?
85
+ resolved_async_options.fetch(:failed) { resolved_async_options[:processing] }
86
+ else
87
+ resolved_async_options[:processing]
88
+ end
42
89
  end
43
90
 
44
- def async_record
45
- blob.variant_records.find_by(variation_digest: variation.digest)
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.
100
+ def find_named_async_variant
101
+ target = variation.transformations.to_json
102
+ scan_for_named_variant(blob, target)
46
103
  end
47
104
 
48
- def fallback_url(...)
49
- case variation.async_options[:fallback]
50
- when :original
51
- blob.url(...)
52
- when :blank
53
- nil
54
- when Proc
55
- variation.async_options[:fallback].call(blob)
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
113
+
114
+ attachment.send(:named_variants).each do |name, _|
115
+ candidate = attachment.variant(name.to_sym)
116
+ if candidate.variation.transformations.to_json == target
117
+ return [attachment, name, candidate.variation.async_options] if candidate.variation.async_options[:processing].present?
118
+ end
119
+ end
56
120
  end
121
+ nil
122
+ end
123
+
124
+ def async_record
125
+ blob.variant_records.find_by(variation_digest: variation.digest)
57
126
  end
58
127
  end
59
128
  end
@@ -3,7 +3,7 @@
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
5
  module VariationExtension
6
- ASYNC_KEYS = %i[fallback transformer max_retries].freeze
6
+ ASYNC_KEYS = %i[processing failed transformer].freeze
7
7
 
8
8
  def initialize(transformations)
9
9
  if transformations.is_a?(Hash)
@@ -13,6 +13,7 @@ module ActiveStorage
13
13
  @async_options = {}
14
14
  super
15
15
  end
16
+ ActiveStorage::AsyncVariants::Registry.register(digest, @async_options) if @async_options[:processing].present?
16
17
  end
17
18
 
18
19
  def async_options
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveStorage
4
4
  module AsyncVariants
5
- VERSION = "0.1.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
@@ -1,37 +1,94 @@
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"
7
+ require_relative "async_variants/registry"
8
+ require_relative "async_variants/blob_extension"
5
9
  require_relative "async_variants/variation_extension"
6
10
  require_relative "async_variants/variant_with_record_extension"
11
+ require_relative "async_variants/variant_record_extension"
12
+ require_relative "async_variants/preview_extension"
7
13
  require_relative "async_variants/attachment_extension"
14
+ require_relative "async_variants/reflection_extension"
8
15
  require_relative "async_variants/process_job"
16
+ require_relative "async_variants/asset_tag_helper_extension"
9
17
 
10
18
  module ActiveStorage
11
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
+
24
+ mattr_accessor :cdn_host
25
+
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"
30
+
12
31
  class Engine < ::Rails::Engine
32
+ # Prepend the core model/reflection extensions before eager_load runs
33
+ # so that models' has_X_attached blocks (and the Variation.wrap calls
34
+ # they trigger via reflection.variant) go through our hooks. The
35
+ # :before_eager_load load_hook fires from the eager_load! initializer
36
+ # in finisher_hook, after all autoload paths have been set up but
37
+ # before any model class is loaded.
38
+ # :nocov:
39
+ ActiveSupport.on_load(:before_eager_load) do
40
+ ActiveStorage::AsyncVariants.prepend_model_extensions!
41
+ end
42
+ # :nocov:
43
+
13
44
  config.after_initialize do
14
- ActiveStorage::Variation.prepend(
15
- ActiveStorage::AsyncVariants::VariationExtension
16
- )
17
- ActiveStorage::VariantWithRecord.prepend(
18
- ActiveStorage::AsyncVariants::VariantWithRecordExtension
19
- )
20
- ActiveStorage::Attachment.prepend(
21
- ActiveStorage::AsyncVariants::AttachmentExtension
45
+ # Idempotent — covers eager_load=false (dev/test) where the
46
+ # :before_eager_load hook never fires. Models autoload lazily on
47
+ # demand, and we just need the extensions in place by the time
48
+ # the first one loads.
49
+ ActiveStorage::AsyncVariants.prepend_model_extensions!
50
+
51
+ ActionView::Helpers::AssetTagHelper.prepend(
52
+ ActiveStorage::AsyncVariants::AssetTagHelperExtension
22
53
  )
23
54
  end
24
55
  end
25
56
 
57
+ def self.prepend_model_extensions!
58
+ require "active_storage/reflection"
59
+ ActiveStorage::Reflection::HasAttachedReflection.prepend(
60
+ ActiveStorage::AsyncVariants::ReflectionExtension
61
+ )
62
+ ActiveStorage::Blob.prepend(
63
+ ActiveStorage::AsyncVariants::BlobExtension
64
+ )
65
+ ActiveStorage::Variation.prepend(
66
+ ActiveStorage::AsyncVariants::VariationExtension
67
+ )
68
+ ActiveStorage::VariantWithRecord.prepend(
69
+ ActiveStorage::AsyncVariants::VariantWithRecordExtension
70
+ )
71
+ ActiveStorage::VariantRecord.include(
72
+ ActiveStorage::AsyncVariants::VariantRecordExtension
73
+ )
74
+ ActiveStorage::Attachment.prepend(
75
+ ActiveStorage::AsyncVariants::AttachmentExtension
76
+ )
77
+ ActiveStorage::Preview.prepend(
78
+ ActiveStorage::AsyncVariants::PreviewExtension
79
+ )
80
+ end
81
+
26
82
  def self.callback_token_for(variant_record)
27
83
  ActiveStorage.verifier.generate(variant_record.id, purpose: :async_variant_callback)
28
84
  end
29
85
 
30
86
  def self.callback_url_for(variant_record)
87
+ url_options = ActiveStorage::Current.url_options || Rails.application.default_url_options
31
88
  token = callback_token_for(variant_record)
32
89
  Rails.application.routes.url_helpers.active_storage_async_variant_callback_url(
33
90
  token: token,
34
- **ActiveStorage::Current.url_options,
91
+ **url_options,
35
92
  )
36
93
  end
37
94
  end
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.1.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-03-04 00:00:00.000000000 Z
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: []
@@ -39,17 +53,30 @@ files:
39
53
  - README.md
40
54
  - Rakefile
41
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
42
62
  - config/routes.rb
63
+ - features/state_rendering.feature
64
+ - features/step_definitions/dummy_steps.rb
65
+ - features/support/env.rb
43
66
  - gemfiles/rails_7.2.gemfile
44
- - gemfiles/rails_7.2.gemfile.lock
45
67
  - gemfiles/rails_8.0.gemfile
46
- - gemfiles/rails_8.0.gemfile.lock
47
68
  - gemfiles/rails_8.1.gemfile
48
- - gemfiles/rails_8.1.gemfile.lock
49
69
  - lib/active_storage/async_variants.rb
70
+ - lib/active_storage/async_variants/asset_tag_helper_extension.rb
50
71
  - lib/active_storage/async_variants/attachment_extension.rb
72
+ - lib/active_storage/async_variants/blob_extension.rb
73
+ - lib/active_storage/async_variants/helper.rb
74
+ - lib/active_storage/async_variants/preview_extension.rb
51
75
  - lib/active_storage/async_variants/process_job.rb
76
+ - lib/active_storage/async_variants/reflection_extension.rb
77
+ - lib/active_storage/async_variants/registry.rb
52
78
  - lib/active_storage/async_variants/transformer.rb
79
+ - lib/active_storage/async_variants/variant_record_extension.rb
53
80
  - lib/active_storage/async_variants/variant_with_record_extension.rb
54
81
  - lib/active_storage/async_variants/variation_extension.rb
55
82
  - lib/active_storage/async_variants/version.rb