assiette 0.3.1 → 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: 1267863146e1fa5c134f47f1cf797123609d8466690d3a39d9603fe9c35787ef
4
- data.tar.gz: fcfbe591ac7de77fddb85bfe0d3b2cee0838ef30ac596aef612712b37294511a
3
+ metadata.gz: b94d7ebde46d04a55abfd53f02d95c2eb6b4d4df1bda833c98089afbefd1ab1b
4
+ data.tar.gz: 342adfcac7ade1a593af2f887883314bd35d1ccae4e87f91e503c0008e8db46a
5
5
  SHA512:
6
- metadata.gz: d525c3fb1891574eac36d5b42ac82d5426b7a60e870a8001f4115edc15f32935e5559623a08ad849a6e580917788c25e16d39d64274deec47aa4291c3b399d8a
7
- data.tar.gz: 3b35f41fec398a1ed00d3b18f2bec5167d14cd0af451d8cc838fa4f5957c21bf8d349e8fcc0a15d54ceeb788a9c78c0bb30e754baf44350c51961cf4e0836d53
6
+ metadata.gz: 26ede00337abc21c2ee238e0e99d7d2143d61cbae84eac72d22210f16cd373c0a41056a9edad881b6a6f049acb353e96605cde191238b6ea643998a4cb255db9
7
+ data.tar.gz: 7c5507d623a0cc264ecbd8f5776a71aef7ad001753d1117c9e0f8cced9fcbc67542a447d4faca3152d89109cea5e2175fc1a72d564aff1cfc22c2f5162e63361
@@ -1,31 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest/sha1"
4
- require "digest/sha2"
5
- require "base64"
6
3
  require "pathname"
7
- require_relative "version_tag"
4
+ require "digest/sha2"
8
5
 
9
6
  module Assiette
10
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.
11
12
  CONTENT_TYPES = {
12
- ".js" => "application/javascript",
13
- ".mjs" => "application/javascript",
13
+ ".js" => JS_CONTENT_TYPE,
14
+ ".mjs" => JS_CONTENT_TYPE,
14
15
  ".css" => "text/css",
15
16
  ".svg" => "image/svg+xml",
16
17
  ".png" => "image/png",
18
+ ".jpg" => "image/jpeg",
19
+ ".jpeg" => "image/jpeg",
17
20
  ".ico" => "image/x-icon"
18
21
  }.freeze
19
22
 
20
23
  JS_EXTENSIONS = %w[.js .mjs].to_set.freeze
21
24
 
22
- def initialize(root:, additional_directory_mappings: {})
25
+ attr_reader :dependency_graph
26
+
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
23
44
  @mappings = build_mappings(root, additional_directory_mappings)
24
- @integrity_cache = {}
25
- @integrity_mutex = Mutex.new
26
- @modules_cache = nil
27
- @modules_mutex = Mutex.new
28
- @modules_version = nil
45
+ @dependency_graph = DependencyGraph.new(self)
46
+ end
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
+
54
+ # Yields (url_path, abs_path) for every file with a recognized extension.
55
+ def each_mapped_file
56
+ @mappings.each do |prefix, root|
57
+ @content_types.each_key do |ext|
58
+ Dir.glob(File.join(root, "**/*#{case_insensitive(ext)}")).each do |abs|
59
+ relative = Pathname.new(abs).relative_path_from(root).to_s
60
+ url_path = if prefix.empty?
61
+ relative
62
+ else
63
+ "#{prefix}/#{relative}"
64
+ end
65
+ yield url_path, abs
66
+ end
67
+ end
68
+ end
29
69
  end
30
70
 
31
71
  def resolve_file(path)
@@ -51,44 +91,139 @@ module Assiette
51
91
  def absolute_asset_url_path(path, script_name = "")
52
92
  clean = path.sub(%r{\A/}, "")
53
93
  return nil unless resolve_file(clean)
54
- "#{script_name}/#{clean}?v=#{Assiette.version_tag}"
94
+ hash = @dependency_graph.tree_sha(clean) || "00000000"
95
+ "#{script_name}/#{clean}?s=#{hash}"
55
96
  end
56
97
 
57
98
  def asset_integrity(path)
58
- version_tag = Assiette.version_tag
59
- @integrity_mutex.synchronize do
60
- if @integrity_version != version_tag
61
- @integrity_cache = {}
62
- @integrity_version = version_tag
63
- end
64
- return @integrity_cache[path] if @integrity_cache.key?(path)
65
-
66
- clean = path.sub(%r{\A/}, "")
67
- @integrity_cache[path] = compute_integrity(clean, version_tag)
68
- end
99
+ clean = path.sub(%r{\A/}, "")
100
+ return nil unless resolve_file(clean)
101
+ @dependency_graph.tree_integrity(clean)
69
102
  end
70
103
 
71
104
  def js_modules
72
- version_tag = Assiette.version_tag
73
- @modules_mutex.synchronize do
74
- return @modules_cache if @modules_version == version_tag
105
+ js_glob = "**/*{#{javascript_extensions.map { |ext| case_insensitive(ext) }.join(",")}}"
106
+ @mappings.flat_map { |prefix, root|
107
+ Dir.glob(File.join(root, js_glob)).filter_map { |abs|
108
+ next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
109
+ relative = Pathname.new(abs).relative_path_from(root).to_s
110
+ mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
111
+ {path: mod_path, integrity: asset_integrity(mod_path)}
112
+ }
113
+ }.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
114
+ end
75
115
 
76
- @modules_cache = @mappings.flat_map { |prefix, root|
77
- Dir[File.join(root, "**/*.{js,mjs}")].filter_map { |abs|
78
- next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
79
- relative = Pathname.new(abs).relative_path_from(root).to_s
80
- mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
81
- {path: mod_path, integrity: asset_integrity(mod_path)}
82
- }
83
- }.uniq { |m| m[:path] }.sort_by { |m| m[:path] }
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
84
130
 
85
- @modules_version = version_tag
86
- @modules_cache
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"
87
151
  end
152
+ combined.hexdigest[0, 16]
88
153
  end
89
154
 
90
155
  private
91
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
+
92
227
  def build_mappings(root, additional_directory_mappings)
93
228
  mappings = [["", Pathname.new(root).expand_path]]
94
229
  additional_directory_mappings.each do |prefix, path|
@@ -97,19 +232,5 @@ module Assiette
97
232
  end
98
233
  mappings
99
234
  end
100
-
101
- def compute_integrity(clean, version_tag)
102
- file_path = resolve_file(clean)
103
- return nil unless file_path
104
-
105
- raw = File.read(file_path)
106
- ext = File.extname(clean)
107
- served = case ext
108
- when ".js", ".mjs" then Rewriter.rewrite_js_imports(raw, version_tag)
109
- when ".css" then Rewriter.rewrite_css_urls(raw, version_tag)
110
- else raw
111
- end
112
- "sha256-#{Base64.strict_encode64(Digest::SHA256.digest(served))}"
113
- end
114
235
  end
115
236
  end
@@ -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
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+ require "base64"
5
+ require "pathname"
6
+
7
+ module Assiette
8
+ class DependencyGraph
9
+ # Per-file node in the dependency graph.
10
+ class Asset
11
+ attr_reader :url_path
12
+ attr_accessor :abs_path, :mtime, :digest, :deps, :dependents
13
+
14
+ def initialize(url_path:, abs_path:, mtime:)
15
+ @url_path = url_path.freeze
16
+ @abs_path = abs_path
17
+ @mtime = mtime
18
+ @digest = nil
19
+ @deps = []
20
+ @dependents = Set.new
21
+ end
22
+
23
+ # 8-char hex hash for ?s= cache-busting query params.
24
+ def checksum_tag
25
+ digest&.unpack1("H8")
26
+ end
27
+
28
+ # SRI integrity string for integrity= attributes.
29
+ def sri_integrity
30
+ return nil unless digest
31
+ "sha256-#{Base64.strict_encode64(digest)}"
32
+ end
33
+
34
+ # Whether this asset's file has changed since last scan.
35
+ def stale?
36
+ File.mtime(abs_path) != mtime
37
+ rescue Errno::ENOENT
38
+ true
39
+ end
40
+
41
+ # Whether the file no longer exists on disk.
42
+ def deleted?
43
+ !File.exist?(abs_path.to_s)
44
+ end
45
+ end
46
+
47
+ def initialize(handler)
48
+ @handler = handler
49
+ @mutex = Mutex.new
50
+ @assets = {}
51
+ end
52
+
53
+ # Returns the Asset for a url_path, or nil.
54
+ def [](url_path)
55
+ @mutex.synchronize do
56
+ ensure_asset!(url_path)
57
+ end
58
+ end
59
+
60
+ # Returns the 8-char hex content hash for a URL path.
61
+ def tree_sha(url_path)
62
+ self[url_path]&.checksum_tag
63
+ end
64
+
65
+ # Returns the SRI integrity string for a URL path.
66
+ def tree_integrity(url_path)
67
+ self[url_path]&.sri_integrity
68
+ end
69
+
70
+ # Resolves a relative import path to a URL path.
71
+ def resolve_import_for(from_url_path, relative_path)
72
+ if relative_path.start_with?("/")
73
+ relative_path.sub(%r{\A/}, "")
74
+ else
75
+ dir = File.dirname(from_url_path)
76
+ File.expand_path(relative_path, "/#{dir}").sub(%r{\A/}, "")
77
+ end
78
+ end
79
+
80
+ # Rewrites content with per-dependency hashes. Thread-safe.
81
+ def rewrite_content(url_path, raw_content)
82
+ @mutex.synchronize do
83
+ ensure_asset!(url_path)
84
+ rewrite_content_internal(url_path, raw_content)
85
+ end
86
+ end
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
+
119
+ # Forces a full rebuild on next access.
120
+ def invalidate!
121
+ @mutex.synchronize do
122
+ @assets = {}
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ # Lazily resolve a single asset and its transitive dependencies.
129
+ # Returns the Asset or nil if the file doesn't exist.
130
+ def ensure_asset!(url_path)
131
+ @resolving = Set.new
132
+ @checked = Set.new
133
+ @cycle_groups = []
134
+ asset = resolve_asset!(url_path)
135
+ # Process any cycles detected during resolution
136
+ @cycle_groups.each { |scc| compute_cycle_digests(scc) }
137
+ @resolving = nil
138
+ @checked = nil
139
+ @cycle_groups = nil
140
+ asset
141
+ end
142
+
143
+ # Recursive resolution. Detects cycles via @resolving set.
144
+ # @checked prevents re-verifying the same asset within one ensure_asset! call.
145
+ # Returns the Asset or nil. Also returns whether anything changed downstream.
146
+ def resolve_asset!(url_path)
147
+ # Already verified fresh in this ensure_asset! call
148
+ return @assets[url_path] if @checked.include?(url_path)
149
+
150
+ existing = @assets[url_path]
151
+ if existing
152
+ if existing.deleted?
153
+ remove_asset!(url_path)
154
+ return nil
155
+ end
156
+
157
+ if existing.stale?
158
+ rescan_asset!(existing)
159
+ @checked << url_path
160
+ return existing
161
+ end
162
+
163
+ # Not stale itself — but deps might be. Check them recursively.
164
+ @checked << url_path
165
+ old_dep_digests = existing.deps.map { |dp| @assets[dp]&.digest }
166
+ existing.deps.each { |dp| resolve_asset!(dp) }
167
+ new_dep_digests = existing.deps.map { |dp| @assets[dp]&.digest }
168
+
169
+ if old_dep_digests != new_dep_digests
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)
178
+ end
179
+
180
+ return existing
181
+ end
182
+
183
+ # Resolve url_path to an absolute path
184
+ abs_path = @handler.resolve_file(url_path)
185
+ return nil unless abs_path
186
+
187
+ # Cycle detection
188
+ unless @resolving.add?(url_path)
189
+ return @assets[url_path]
190
+ end
191
+
192
+ # Create the asset node
193
+ asset = @assets[url_path] = Asset.new(
194
+ url_path: url_path,
195
+ abs_path: abs_path,
196
+ mtime: File.mtime(abs_path)
197
+ )
198
+ asset.deps = parse_deps(url_path, File.read(abs_path))
199
+
200
+ # Recursively resolve dependencies
201
+ in_cycle = false
202
+ asset.deps.each do |dep_path|
203
+ if @resolving.include?(dep_path) && !@assets[dep_path]&.digest
204
+ in_cycle = true
205
+ end
206
+ dep = resolve_asset!(dep_path)
207
+ dep.dependents << url_path if dep
208
+ end
209
+
210
+ if in_cycle || asset.deps.any? { |dp| @resolving.include?(dp) && !@assets[dp]&.digest }
211
+ scc = collect_cycle(url_path)
212
+ @cycle_groups << scc unless scc.empty?
213
+ else
214
+ compute_digest_for(url_path)
215
+ end
216
+
217
+ @checked << url_path
218
+ @resolving.delete(url_path)
219
+ asset
220
+ end
221
+
222
+ # Collect strongly connected component members starting from url_path.
223
+ def collect_cycle(url_path)
224
+ visited = Set.new
225
+ stack = [url_path]
226
+ members = Set.new
227
+
228
+ while (node = stack.pop)
229
+ next unless visited.add?(node)
230
+ asset = @assets[node]
231
+ next unless asset
232
+ members << node if @resolving.include?(node)
233
+ asset.deps.each do |dep|
234
+ stack << dep if @resolving.include?(dep)
235
+ end
236
+ end
237
+
238
+ members.to_a
239
+ end
240
+
241
+ # Re-read a stale asset, re-parse deps, recursively ensure deps fresh, recompute digest.
242
+ def rescan_asset!(asset)
243
+ url_path = asset.url_path
244
+ asset.mtime = File.mtime(asset.abs_path)
245
+
246
+ # Remove old reverse links
247
+ asset.deps.each do |dep_path|
248
+ dep = @assets[dep_path]
249
+ dep&.dependents&.delete(url_path)
250
+ end
251
+
252
+ # Re-parse imports
253
+ raw = File.read(asset.abs_path)
254
+ asset.deps = parse_deps(url_path, raw)
255
+
256
+ # Recursively ensure deps are fresh
257
+ asset.deps.each do |dep_path|
258
+ dep = resolve_asset!(dep_path)
259
+ dep.dependents << url_path if dep
260
+ end
261
+
262
+ # Recompute digest
263
+ compute_digest_for(url_path)
264
+
265
+ # Propagate to dependents already in the graph
266
+ propagate_to_dependents!(asset)
267
+ end
268
+
269
+ # Recompute digests for all transitive dependents of an asset.
270
+ def propagate_to_dependents!(asset)
271
+ queue = asset.dependents.to_a
272
+ visited = Set.new
273
+ while (dep_url = queue.shift)
274
+ next unless visited.add?(dep_url)
275
+ dep_asset = @assets[dep_url]
276
+ next unless dep_asset
277
+ compute_digest_for(dep_url)
278
+ queue.concat(dep_asset.dependents.to_a)
279
+ end
280
+ end
281
+
282
+ def parse_deps(url_path, raw)
283
+ ext = File.extname(url_path)
284
+ import_paths = case ext
285
+ when ".js", ".mjs", ".es" then Rewriter.extract_js_imports(raw)
286
+ when ".css" then Rewriter.extract_css_urls(raw)
287
+ else []
288
+ end
289
+ import_paths.map { |p| resolve_import_for(url_path, p) }
290
+ end
291
+
292
+ def compute_digest_for(url_path)
293
+ asset = @assets[url_path]
294
+ return unless asset
295
+
296
+ raw = File.read(asset.abs_path)
297
+ if asset.deps.empty?
298
+ asset.digest = Digest::SHA256.digest(raw)
299
+ else
300
+ rewritten = rewrite_content_internal(url_path, raw)
301
+ asset.digest = Digest::SHA256.digest(rewritten)
302
+ end
303
+ end
304
+
305
+ # For cyclic dependencies: hash all members' raw contents together.
306
+ def compute_cycle_digests(scc)
307
+ combined = scc.sort.filter_map { |url_path|
308
+ asset = @assets[url_path]
309
+ next unless asset
310
+ File.read(asset.abs_path)
311
+ }.join("\0")
312
+
313
+ digest = Digest::SHA256.digest(combined)
314
+ scc.each do |url_path|
315
+ asset = @assets[url_path]
316
+ asset.digest = digest if asset
317
+ end
318
+ end
319
+
320
+ def rewrite_content_internal(url_path, raw_content)
321
+ ext = File.extname(url_path)
322
+ case ext
323
+ when ".js", ".mjs", ".es"
324
+ Rewriter.rewrite_js_imports(raw_content) do |import_path|
325
+ resolved = resolve_import_for(url_path, import_path)
326
+ @assets[resolved]&.checksum_tag || "00000000"
327
+ end
328
+ when ".css"
329
+ Rewriter.rewrite_css_urls(raw_content) do |ref_path|
330
+ resolved = resolve_import_for(url_path, ref_path)
331
+ @assets[resolved]&.checksum_tag || "00000000"
332
+ end
333
+ else
334
+ raw_content
335
+ end
336
+ end
337
+
338
+ def remove_asset!(url_path)
339
+ asset = @assets.delete(url_path)
340
+ return unless asset
341
+
342
+ asset.deps.each do |dep_path|
343
+ dep = @assets[dep_path]
344
+ dep&.dependents&.delete(url_path)
345
+ end
346
+ end
347
+ end
348
+ end
@@ -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
@@ -15,16 +15,37 @@ module Assiette
15
15
 
16
16
  module_function
17
17
 
18
- def rewrite_js_imports(source, version_tag)
18
+ # Rewrites JS imports with per-import hashes via a block.
19
+ # The block receives the import path and must return the hash for that import.
20
+ def rewrite_js_imports(source, &block)
19
21
  source.gsub(JS_IMPORT_RE) do
20
- "#{$1}#{$2}?v=#{version_tag}#{$1}"
22
+ quote = $1
23
+ path = $2
24
+ hash = yield(path)
25
+ "#{quote}#{path}?s=#{hash}#{quote}"
21
26
  end
22
27
  end
23
28
 
24
- def rewrite_css_urls(source, version_tag)
29
+ # Rewrites CSS url() references with per-url hashes via a block.
30
+ # The block receives the url path and must return the hash for that url.
31
+ def rewrite_css_urls(source, &block)
25
32
  source.gsub(CSS_URL_RE) do
26
- "url(#{$1}#{$2}?v=#{version_tag}#{$3})"
33
+ open = $1
34
+ path = $2
35
+ close = $3
36
+ hash = yield(path)
37
+ "url(#{open}#{path}?s=#{hash}#{close})"
27
38
  end
28
39
  end
40
+
41
+ # Returns an array of import paths found in JS source.
42
+ def extract_js_imports(source)
43
+ source.scan(JS_IMPORT_RE).map { |m| m[1] }
44
+ end
45
+
46
+ # Returns an array of url() paths found in CSS source.
47
+ def extract_css_urls(source)
48
+ source.scan(CSS_URL_RE).map { |m| m[1] }
49
+ end
29
50
  end
30
51
  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,37 +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
- raw_bytes = File.binread(file_path)
41
-
42
- # ETag from raw bytes for stability
43
- etag = %("#{Digest::SHA1.hexdigest(raw_bytes)}")
54
+ # Use the dependency graph's content hash for the ETag — it reflects
55
+ # the file's own content plus all its transitive dependencies, and is
56
+ # already computed as part of serving. This lets us short-circuit with
57
+ # a 304 before reading the file for rewriting.
58
+ etag = %("#{handler.dependency_graph.tree_sha(path_info) || "0"}")
44
59
  if env["HTTP_IF_NONE_MATCH"] == etag
45
60
  return [304, {"etag" => etag, "cache-control" => CACHE_CONTROL}, []]
46
61
  end
47
62
 
48
- query = Rack::Utils.parse_query(env["QUERY_STRING"])
49
- tag = query["v"].to_s.empty? ? Assiette.version_tag : query["v"]
50
-
51
- body = if AssetHandler::JS_EXTENSIONS.include?(extension)
52
- Rewriter.rewrite_js_imports(raw_bytes, tag)
53
- elsif extension == ".css"
54
- Rewriter.rewrite_css_urls(raw_bytes, tag)
55
- else
56
- raw_bytes
57
- end
63
+ raw_bytes = File.binread(file_path)
64
+ body = handler.dependency_graph.rewrite_content(path_info, raw_bytes)
58
65
 
59
66
  headers = {
60
67
  "content-type" => content_type,
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Assiette
4
- VERSION = "0.3.1"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/assiette.rb CHANGED
@@ -5,9 +5,11 @@ require_relative "assiette/version"
5
5
  module Assiette
6
6
  autoload :Rewriter, File.expand_path("assiette/rewriter", __dir__)
7
7
  autoload :AssetHandler, File.expand_path("assiette/asset_handler", __dir__)
8
+ autoload :DependencyGraph, File.expand_path("assiette/dependency_graph", __dir__)
8
9
  autoload :Server, File.expand_path("assiette/server", __dir__)
9
10
  autoload :Helpers, File.expand_path("assiette/helpers", __dir__)
10
11
  autoload :RailsAssetUrlHelper, File.expand_path("assiette/rails_asset_url_helper", __dir__)
12
+ autoload :ControllerEtag, File.expand_path("assiette/controller_etag", __dir__)
11
13
  end
12
14
 
13
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.3.1
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,13 +36,14 @@ files:
35
36
  - Rakefile
36
37
  - lib/assiette.rb
37
38
  - lib/assiette/asset_handler.rb
39
+ - lib/assiette/controller_etag.rb
40
+ - lib/assiette/dependency_graph.rb
38
41
  - lib/assiette/helpers.rb
39
42
  - lib/assiette/rails_asset_url_helper.rb
40
43
  - lib/assiette/railtie.rb
41
44
  - lib/assiette/rewriter.rb
42
45
  - lib/assiette/server.rb
43
46
  - lib/assiette/version.rb
44
- - lib/assiette/version_tag.rb
45
47
  - lib/generators/assiette/install/install_generator.rb
46
48
  - lib/generators/assiette/install/templates/initializer.rb.tt
47
49
  homepage: https://github.com/julik/assiette
@@ -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: []
@@ -1,33 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest/sha1"
4
-
5
- module Assiette
6
- # Computes a short version tag for cache busting.
7
- # In development: timestamp for instant invalidation on each request
8
- # In production: derived from APP_REVISION env var or Gemfile.lock digest
9
- def self.version_tag
10
- @version_tag ||= compute_version_tag
11
- end
12
-
13
- def self.reset_version_tag!
14
- @version_tag = nil
15
- end
16
-
17
- def self.compute_version_tag
18
- if Rails.env.development?
19
- Time.now.utc.strftime("%Y%m%d%H%M%S")
20
- elsif (app_revision = ENV["APP_REVISION"]).present?
21
- Digest::SHA1.hexdigest(app_revision)[0, 4]
22
- else
23
- gemfile_lock_path = Rails.root.join("Gemfile.lock")
24
- if gemfile_lock_path.exist?
25
- Digest::SHA1.file(gemfile_lock_path).hexdigest[0, 4]
26
- else
27
- (Time.now.utc.to_i / 300).to_s(16)
28
- end
29
- end
30
- end
31
-
32
- private_class_method :compute_version_tag
33
- end