assiette 0.4.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f24fbf1362c382584825ebb9ea4adf6b0abf0e24ad0b00c0792810b469152be
4
- data.tar.gz: 747cafe7a7af67efec260a8ff506eecb390b69225fd8aebf7ff52122175f7a4f
3
+ metadata.gz: b94d7ebde46d04a55abfd53f02d95c2eb6b4d4df1bda833c98089afbefd1ab1b
4
+ data.tar.gz: 342adfcac7ade1a593af2f887883314bd35d1ccae4e87f91e503c0008e8db46a
5
5
  SHA512:
6
- metadata.gz: df6146e2288d39315cd1d045e1af7c7b75e07fc89022d3288c7d52f544e01f4be7bdc1ee5a5a9fcf49d788cf967419801a7259e8477a57f3b3274a59475db1ad
7
- data.tar.gz: d29c94ba6524db67f1c8f85a024c1648a9e1ea9ccf60078528ebcf24b1fbfd818181ce3c379b061519bfbd53a75ede042a15fda3734c47baddb7832cde3d4fd3
6
+ metadata.gz: 26ede00337abc21c2ee238e0e99d7d2143d61cbae84eac72d22210f16cd373c0a41056a9edad881b6a6f049acb353e96605cde191238b6ea643998a4cb255db9
7
+ data.tar.gz: 7c5507d623a0cc264ecbd8f5776a71aef7ad001753d1117c9e0f8cced9fcbc67542a447d4faca3152d89109cea5e2175fc1a72d564aff1cfc22c2f5162e63361
@@ -1,15 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pathname"
4
+ require "digest/sha2"
4
5
 
5
6
  module Assiette
6
7
  class AssetHandler
8
+ JS_CONTENT_TYPE = "application/javascript"
9
+
10
+ # The extensions every handler serves. Individual handlers can add to this
11
+ # (or override it) through the `content_types:` argument — see #initialize.
7
12
  CONTENT_TYPES = {
8
- ".js" => "application/javascript",
9
- ".mjs" => "application/javascript",
13
+ ".js" => JS_CONTENT_TYPE,
14
+ ".mjs" => JS_CONTENT_TYPE,
10
15
  ".css" => "text/css",
11
16
  ".svg" => "image/svg+xml",
12
17
  ".png" => "image/png",
18
+ ".jpg" => "image/jpeg",
19
+ ".jpeg" => "image/jpeg",
13
20
  ".ico" => "image/x-icon"
14
21
  }.freeze
15
22
 
@@ -17,16 +24,38 @@ module Assiette
17
24
 
18
25
  attr_reader :dependency_graph
19
26
 
20
- def initialize(root:, additional_directory_mappings: {})
27
+ # Every extension this handler serves, as ".ext" => content type. This is
28
+ # CONTENT_TYPES with the `content_types:` argument merged over it.
29
+ attr_reader :content_types
30
+
31
+ # `content_types` registers extra extensions on this handler alone, merged
32
+ # over CONTENT_TYPES — so it can also override a default mapping. The
33
+ # defaults themselves are never touched: a handler that was not given an
34
+ # extension keeps refusing it.
35
+ #
36
+ # AssetHandler.new(root: "...", content_types: {".woff2" => "font/woff2"})
37
+ #
38
+ # Extensions are normalized to a leading dot and lowercase, so "woff2",
39
+ # ".woff2" and ".WOFF2" all register the same thing. Lookups and globs are
40
+ # case-insensitive as well, so a file on disk named PHOTO.JPG is served
41
+ # just like photo.jpg.
42
+ def initialize(root:, additional_directory_mappings: {}, content_types: {})
43
+ @content_types = CONTENT_TYPES.merge(content_types.to_h { |ext, type| [normalize_extension(ext), type] }).freeze
21
44
  @mappings = build_mappings(root, additional_directory_mappings)
22
45
  @dependency_graph = DependencyGraph.new(self)
23
46
  end
24
47
 
48
+ # The content type this handler serves `path` as, or nil if its extension
49
+ # is not one this handler knows.
50
+ def content_type_for(path)
51
+ @content_types[normalize_extension(File.extname(path))]
52
+ end
53
+
25
54
  # Yields (url_path, abs_path) for every file with a recognized extension.
26
55
  def each_mapped_file
27
56
  @mappings.each do |prefix, root|
28
- CONTENT_TYPES.each_key do |ext|
29
- Dir[File.join(root, "**/*#{ext}")].each do |abs|
57
+ @content_types.each_key do |ext|
58
+ Dir.glob(File.join(root, "**/*#{case_insensitive(ext)}")).each do |abs|
30
59
  relative = Pathname.new(abs).relative_path_from(root).to_s
31
60
  url_path = if prefix.empty?
32
61
  relative
@@ -73,8 +102,9 @@ module Assiette
73
102
  end
74
103
 
75
104
  def js_modules
105
+ js_glob = "**/*{#{javascript_extensions.map { |ext| case_insensitive(ext) }.join(",")}}"
76
106
  @mappings.flat_map { |prefix, root|
77
- Dir[File.join(root, "**/*.{js,mjs}")].filter_map { |abs|
107
+ Dir.glob(File.join(root, js_glob)).filter_map { |abs|
78
108
  next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
79
109
  relative = Pathname.new(abs).relative_path_from(root).to_s
80
110
  mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
@@ -83,8 +113,117 @@ module Assiette
83
113
  }.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
84
114
  end
85
115
 
116
+ # A single 16-char hex hash covering every asset this handler can serve.
117
+ # Fold it into a page's ETag and the page stops validating as soon as any
118
+ # Assiette URL on it would come out different.
119
+ #
120
+ # This is not a sweep over every mapped file — it hashes the dependency
121
+ # graph's apexes, whose digests already fold in everything they reach.
122
+ def digest
123
+ ensure_graph_populated!
124
+ combined = Digest::SHA256.new
125
+ @dependency_graph.apex_paths.each do |url_path|
126
+ combined << url_path << "\0" << @dependency_graph.tree_sha(url_path).to_s << "\0"
127
+ end
128
+ combined.hexdigest[0, 16]
129
+ end
130
+
131
+ # A hash covering exactly the assets named in `url_paths`, for a page that
132
+ # knows its own entry points and wants a validator scoped to them:
133
+ #
134
+ # fresh_when(@post, etag: handler.digest_for(["/application.css", "/js/app.js"]))
135
+ #
136
+ # Naming the nodes is what makes this cheap, and short. Each one's
137
+ # fingerprint already folds in its whole import subtree, so one entry module
138
+ # covers however many files hang off it, and nothing has to be discovered —
139
+ # no glob, no apex set, no populated graph, just one `File.mtime` per named
140
+ # node and per node below it that the graph already holds.
141
+ #
142
+ # An unknown path hashes as the empty string, so a page keeps busting when
143
+ # an asset it links is deleted or renamed away. What this cannot cover is a
144
+ # page that renders a listing of whatever the handler holds — the
145
+ # modulepreload tags — because that depends on which files exist, not only
146
+ # on the ones named here. Use #digest for those.
147
+ def digest_for(url_paths)
148
+ combined = Digest::SHA256.new
149
+ url_paths.map { |path| path.sub(%r{\A/}, "") }.uniq.sort.each do |url_path|
150
+ combined << url_path << "\0" << @dependency_graph.tree_sha(url_path).to_s << "\0"
151
+ end
152
+ combined.hexdigest[0, 16]
153
+ end
154
+
86
155
  private
87
156
 
157
+ # The extensions served as JavaScript: .js and .mjs, plus anything this
158
+ # handler had registered with a JavaScript content type.
159
+ def javascript_extensions
160
+ @content_types.select { |_ext, content_type| content_type == JS_CONTENT_TYPE }.keys
161
+ end
162
+
163
+ # Dir.glob ignores File::FNM_CASEFOLD - "Case sensitivity depends on your
164
+ # system" - so on Linux a "*.jpg" pattern walks straight past PHOTO.JPG.
165
+ # Fold the case into the pattern itself instead: ".jpg" => ".[jJ][pP][gG]".
166
+ def case_insensitive(extension)
167
+ extension.gsub(/[a-z]/) { |char| "[#{char}#{char.upcase}]" }
168
+ end
169
+
170
+ # ".JPG", "jpg" and ".jpg" all name the same extension.
171
+ def normalize_extension(extension)
172
+ ext = extension.to_s.downcase
173
+ ext.start_with?(".") ? ext : ".#{ext}"
174
+ end
175
+
176
+ # The apex set is only meaningful over a fully populated graph, and the
177
+ # graph is lazy — asking it opportunistically gives different answers
178
+ # depending on what has been requested so far, which would have two Puma
179
+ # workers computing different ETags for identical content.
180
+ #
181
+ # The walk is amortised behind a directory-mtime guard rather than a glob
182
+ # per call. Directory mtimes move when an entry is added, removed or
183
+ # renamed — exactly the events that change the file list. Editing a file in
184
+ # place does not move them, and does not need to: the graph checks per-file
185
+ # mtimes on every access.
186
+ def ensure_graph_populated!
187
+ return if watched_directories_unchanged?
188
+ each_mapped_file { |url_path, _abs_path| @dependency_graph[url_path] }
189
+ @dependency_graph.prune_deleted!
190
+ @watched_directories = directory_mtimes
191
+ end
192
+
193
+ def watched_directories_unchanged?
194
+ return false unless @watched_directories
195
+ @watched_directories.all? { |dir, mtime| File.mtime(dir) == mtime }
196
+ rescue Errno::ENOENT
197
+ false
198
+ end
199
+
200
+ # Every directory under every mapped root, with its mtime.
201
+ #
202
+ # Watching only the directories that held a servable file leaves a hole
203
+ # right where it hurts. Adding app/assets/js/new_thing/a.js moves the mtime
204
+ # of app/assets/js — but if that directory holds no servable file of its
205
+ # own it was never watched, and the new asset stays invisible to the
206
+ # digest: every page keeps validating while linking HTML that has no idea
207
+ # the file exists. Watching the intermediate directories too costs one more
208
+ # `stat` apiece per call and closes it.
209
+ #
210
+ # nil rather than an empty Hash when there is nothing to watch, because an
211
+ # empty Hash reads as "nothing changed" forever — a root that does not
212
+ # exist yet would never be picked up once it did.
213
+ def directory_mtimes
214
+ mtimes = {}
215
+ @mappings.each do |_prefix, root|
216
+ next unless root.directory?
217
+ [root.to_s, *Dir.glob(File.join(root, "**/"))].each do |dir|
218
+ path = dir.chomp("/")
219
+ mtimes[path] ||= File.mtime(path)
220
+ end
221
+ end
222
+ mtimes.empty? ? nil : mtimes
223
+ rescue Errno::ENOENT
224
+ nil # something vanished mid-walk; re-walk on the next call
225
+ end
226
+
88
227
  def build_mappings(root, additional_directory_mappings)
89
228
  mappings = [["", Pathname.new(root).expand_path]]
90
229
  additional_directory_mappings.each do |prefix, path|
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Assiette
4
+ # Extends ActionController::Base with a one-line macro that folds the
5
+ # Assiette apex digest into every ETag the controller generates.
6
+ #
7
+ # class ApplicationController < ActionController::Base
8
+ # include_assiette_etags!
9
+ # end
10
+ #
11
+ # Without this, a page whose ETag is built from the code revision plus model
12
+ # cache keys keeps validating after you edit a stylesheet — neither of those
13
+ # moved — and a caching layer happily serves stored HTML linking the previous
14
+ # ?s= hash.
15
+ #
16
+ # The value folded in covers every asset the request's handlers can serve,
17
+ # rather than the ones this particular page happens to link. That is the
18
+ # coarse answer, and it is the only one available before the template runs:
19
+ # `etag` blocks are evaluated inside `fresh_when`, in the action, and skipping
20
+ # the render is the entire point of a conditional GET. A page that wants a
21
+ # narrower validator can name its entry points and pass
22
+ # AssetHandler#digest_for to fresh_when itself.
23
+ module ControllerEtag
24
+ def include_assiette_etags!
25
+ etag do
26
+ # Every handler that saw the request, not only the innermost one. The
27
+ # view helpers resolve an asset against the whole stack, so a page can
28
+ # link assets an outer handler serves — and a change to one of those
29
+ # has to move this page's ETag just the same.
30
+ #
31
+ # Resolved off the Rack env rather than Rails.application.assets so
32
+ # this works in mode 1 too.
33
+ stack = request.env["assiette.stack"]
34
+ stack.map { |entry| entry[:handler].digest }.join("/") if stack&.any?
35
+ end
36
+ end
37
+ end
38
+ end
@@ -85,6 +85,37 @@ module Assiette
85
85
  end
86
86
  end
87
87
 
88
+ # URL paths of the graph's apexes — assets nothing else imports or
89
+ # references. Every apex's digest already folds in its entire dependency
90
+ # subtree, so hashing the apexes hashes the whole graph with each file
91
+ # counted exactly once.
92
+ #
93
+ # A cycle that nothing points into has no member with empty dependents, so
94
+ # it would silently drop out. Rather than keeping SCC bookkeeping around
95
+ # (it is discarded once resolution finishes), walk down from the apexes and
96
+ # adopt whatever was never reached.
97
+ def apex_paths
98
+ @mutex.synchronize do
99
+ apexes = @assets.each_value.select { |asset| asset.dependents.empty? }.map(&:url_path)
100
+ reached = Set.new
101
+ queue = apexes.dup
102
+ while (url_path = queue.shift)
103
+ next unless reached.add?(url_path)
104
+ queue.concat(@assets[url_path]&.deps || [])
105
+ end
106
+ (apexes + (@assets.keys - reached.to_a)).sort
107
+ end
108
+ end
109
+
110
+ # Drops nodes whose files have disappeared from disk. Assets that something
111
+ # still imports get pruned as a side effect of resolving their dependents,
112
+ # but an orphan that nobody points at is never revisited and would linger.
113
+ def prune_deleted!
114
+ @mutex.synchronize do
115
+ @assets.each_value.select(&:deleted?).each { |asset| remove_asset!(asset.url_path) }
116
+ end
117
+ end
118
+
88
119
  # Forces a full rebuild on next access.
89
120
  def invalidate!
90
121
  @mutex.synchronize do
@@ -137,6 +168,13 @@ module Assiette
137
168
 
138
169
  if old_dep_digests != new_dep_digests
139
170
  compute_digest_for(url_path)
171
+ # And tell whoever imports this one, exactly as rescan_asset! does.
172
+ # Without it a node recomputed here keeps stale dependents: they
173
+ # compare their deps' digests from before and after their own visit,
174
+ # and a dep already updated during an earlier visit looks unchanged.
175
+ # Their rewritten content carries the new ?s= while their own
176
+ # fingerprint still says otherwise, so nobody refetches them.
177
+ propagate_to_dependents!(existing)
140
178
  end
141
179
 
142
180
  return existing
@@ -3,18 +3,18 @@
3
3
  module Assiette
4
4
  module Helpers
5
5
  # Returns the URL path to an asset served by Assiette, with a cache-busting
6
- # version tag appended.
6
+ # version tag appended. Returns nil if no handler in the stack has the file.
7
7
  def assiette_asset_path(path)
8
- entry = request.env["assiette.stack"]&.last
9
- raise "No Assiette::Server in middleware stack" unless entry
8
+ entry = assiette_entry_resolving(path)
9
+ return unless entry
10
10
  entry[:handler].absolute_asset_url_path(path, entry[:script_name])
11
11
  end
12
12
 
13
13
  # Returns the SRI integrity hash for an asset, computed over the served
14
14
  # (rewritten) content. Returns nil if the file is not found.
15
15
  def assiette_asset_integrity(path)
16
- entry = request.env["assiette.stack"]&.last
17
- raise "No Assiette::Server in middleware stack" unless entry
16
+ entry = assiette_entry_resolving(path)
17
+ return unless entry
18
18
  entry[:handler].asset_integrity(path)
19
19
  end
20
20
 
@@ -27,14 +27,36 @@ module Assiette
27
27
  # Generates <link rel="modulepreload"> tags for all detected ES modules
28
28
  # under the configured asset roots. Each tag includes an SRI integrity
29
29
  # hash computed over the served (rewritten) content.
30
+ #
31
+ # Unlike the path helpers this stays scoped to a single handler — the
32
+ # innermost Server in the stack — on purpose. It renders a listing of
33
+ # everything a handler holds, so walking the stack would put one tenant's
34
+ # file list on another tenant's page.
30
35
  def assiette_modulepreload_tags
31
- entry = request.env["assiette.stack"]&.last
32
- raise "No Assiette::Server in middleware stack" unless entry
33
- modules = entry[:handler].js_modules
34
- safe_join(modules.map { |mod|
35
- tag.link(rel: "modulepreload", href: assiette_asset_path(mod[:path]),
36
+ entry = assiette_stack.last
37
+ handler = entry[:handler]
38
+ safe_join(handler.js_modules.map { |mod|
39
+ tag.link(rel: "modulepreload",
40
+ href: handler.absolute_asset_url_path(mod[:path], entry[:script_name]),
36
41
  integrity: mod[:integrity], crossorigin: "anonymous")
37
42
  }, "\n")
38
43
  end
44
+
45
+ private
46
+
47
+ def assiette_stack
48
+ stack = request.env["assiette.stack"]
49
+ raise "No Assiette::Server in middleware stack" if stack.nil? || stack.empty?
50
+ stack
51
+ end
52
+
53
+ # Walks the stack from the innermost Server outwards and returns the first
54
+ # entry whose handler actually has the file. With one Server mounted that is
55
+ # simply the only entry; with several — an app serving a per-tenant
56
+ # directory next to a shared one — the innermost is not necessarily the one
57
+ # holding the asset a page asks for. Returns nil if none of them has it.
58
+ def assiette_entry_resolving(path)
59
+ assiette_stack.reverse_each.find { |entry| entry[:handler].resolve_file(path) }
60
+ end
39
61
  end
40
62
  end
@@ -13,5 +13,11 @@ module Assiette
13
13
  include Assiette::RailsAssetUrlHelper
14
14
  end
15
15
  end
16
+
17
+ initializer "assiette.controller_etag" do
18
+ ActiveSupport.on_load(:action_controller_base) do
19
+ extend Assiette::ControllerEtag
20
+ end
21
+ end
16
22
  end
17
23
  end
@@ -4,19 +4,30 @@ module Assiette
4
4
  class Server
5
5
  CACHE_CONTROL = "public, max-age=432000, must-revalidate"
6
6
 
7
- # Accepts either a pre-built handler or keyword args:
7
+ # Accepts a pre-built handler, something callable returning a handler for
8
+ # the request at hand, or keyword args:
8
9
  # Server.new(app, handler)
10
+ # Server.new(app, ->(env) { Tenant.for(env)&.assiette_handler })
9
11
  # Server.new(app, root: "...", additional_directory_mappings: {})
12
+ #
13
+ # The callable form is for applications whose served locations vary per
14
+ # request — a multi-tenant app hands every tenant its own AssetHandler
15
+ # rooted in that tenant's directory. It is called once per request with the
16
+ # Rack env and may return nil, meaning "nothing to serve here": the request
17
+ # then passes straight through, with no entry added to the handler stack.
10
18
  def initialize(app, handler = nil, root: nil, additional_directory_mappings: {})
11
19
  @app = app
12
20
  @handler = handler || AssetHandler.new(root: root, additional_directory_mappings: additional_directory_mappings)
13
21
  end
14
22
 
15
23
  def call(env)
24
+ handler = resolve_handler(env)
25
+ return @app.call(env) unless handler
26
+
16
27
  stack = (env["assiette.stack"] ||= [])
17
- stack << {handler: @handler, script_name: env["SCRIPT_NAME"].to_s}
28
+ stack << {handler: handler, script_name: env["SCRIPT_NAME"].to_s}
18
29
 
19
- result = serve(env)
30
+ result = serve(env, handler)
20
31
  return result if result
21
32
 
22
33
  @app.call(env)
@@ -24,30 +35,33 @@ module Assiette
24
35
 
25
36
  private
26
37
 
27
- def serve(env)
38
+ def resolve_handler(env)
39
+ @handler.respond_to?(:call) ? @handler.call(env) : @handler
40
+ end
41
+
42
+ def serve(env, handler)
28
43
  return unless env["REQUEST_METHOD"] == "GET" || env["REQUEST_METHOD"] == "HEAD"
29
44
 
30
45
  path_info = Rack::Utils.unescape_path(env["PATH_INFO"])
31
46
  path_info = path_info.sub(%r{\A/}, "")
32
47
 
33
- extension = File.extname(path_info)
34
- content_type = AssetHandler::CONTENT_TYPES[extension]
48
+ content_type = handler.content_type_for(path_info)
35
49
  return unless content_type
36
50
 
37
- file_path = @handler.resolve_file(path_info)
51
+ file_path = handler.resolve_file(path_info)
38
52
  return unless file_path
39
53
 
40
54
  # Use the dependency graph's content hash for the ETag — it reflects
41
55
  # the file's own content plus all its transitive dependencies, and is
42
56
  # already computed as part of serving. This lets us short-circuit with
43
57
  # a 304 before reading the file for rewriting.
44
- etag = %("#{@handler.dependency_graph.tree_sha(path_info) || "0"}")
58
+ etag = %("#{handler.dependency_graph.tree_sha(path_info) || "0"}")
45
59
  if env["HTTP_IF_NONE_MATCH"] == etag
46
60
  return [304, {"etag" => etag, "cache-control" => CACHE_CONTROL}, []]
47
61
  end
48
62
 
49
63
  raw_bytes = File.binread(file_path)
50
- body = @handler.dependency_graph.rewrite_content(path_info, raw_bytes)
64
+ body = handler.dependency_graph.rewrite_content(path_info, raw_bytes)
51
65
 
52
66
  headers = {
53
67
  "content-type" => content_type,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Assiette
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/assiette.rb CHANGED
@@ -9,6 +9,7 @@ module Assiette
9
9
  autoload :Server, File.expand_path("assiette/server", __dir__)
10
10
  autoload :Helpers, File.expand_path("assiette/helpers", __dir__)
11
11
  autoload :RailsAssetUrlHelper, File.expand_path("assiette/rails_asset_url_helper", __dir__)
12
+ autoload :ControllerEtag, File.expand_path("assiette/controller_etag", __dir__)
12
13
  end
13
14
 
14
15
  require_relative "assiette/railtie" if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: assiette
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2026-05-26 00:00:00.000000000 Z
11
+ date: 2026-09-13 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: actionpack
@@ -35,6 +36,7 @@ files:
35
36
  - Rakefile
36
37
  - lib/assiette.rb
37
38
  - lib/assiette/asset_handler.rb
39
+ - lib/assiette/controller_etag.rb
38
40
  - lib/assiette/dependency_graph.rb
39
41
  - lib/assiette/helpers.rb
40
42
  - lib/assiette/rails_asset_url_helper.rb
@@ -52,6 +54,7 @@ metadata:
52
54
  homepage_uri: https://github.com/julik/assiette
53
55
  source_code_uri: https://github.com/julik/assiette
54
56
  changelog_uri: https://github.com/julik/assiette/blob/main/CHANGELOG.md
57
+ post_install_message:
55
58
  rdoc_options: []
56
59
  require_paths:
57
60
  - lib
@@ -66,7 +69,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
66
69
  - !ruby/object:Gem::Version
67
70
  version: '0'
68
71
  requirements: []
69
- rubygems_version: 3.6.6
72
+ rubygems_version: 3.4.10
73
+ signing_key:
70
74
  specification_version: 4
71
75
  summary: Zero-build asset serving for Rails engines
72
76
  test_files: []