assiette 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1267863146e1fa5c134f47f1cf797123609d8466690d3a39d9603fe9c35787ef
4
- data.tar.gz: fcfbe591ac7de77fddb85bfe0d3b2cee0838ef30ac596aef612712b37294511a
3
+ metadata.gz: 4f24fbf1362c382584825ebb9ea4adf6b0abf0e24ad0b00c0792810b469152be
4
+ data.tar.gz: 747cafe7a7af67efec260a8ff506eecb390b69225fd8aebf7ff52122175f7a4f
5
5
  SHA512:
6
- metadata.gz: d525c3fb1891574eac36d5b42ac82d5426b7a60e870a8001f4115edc15f32935e5559623a08ad849a6e580917788c25e16d39d64274deec47aa4291c3b399d8a
7
- data.tar.gz: 3b35f41fec398a1ed00d3b18f2bec5167d14cd0af451d8cc838fa4f5957c21bf8d349e8fcc0a15d54ceeb788a9c78c0bb30e754baf44350c51961cf4e0836d53
6
+ metadata.gz: df6146e2288d39315cd1d045e1af7c7b75e07fc89022d3288c7d52f544e01f4be7bdc1ee5a5a9fcf49d788cf967419801a7259e8477a57f3b3274a59475db1ad
7
+ data.tar.gz: d29c94ba6524db67f1c8f85a024c1648a9e1ea9ccf60078528ebcf24b1fbfd818181ce3c379b061519bfbd53a75ede042a15fda3734c47baddb7832cde3d4fd3
@@ -1,10 +1,6 @@
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"
8
4
 
9
5
  module Assiette
10
6
  class AssetHandler
@@ -19,13 +15,28 @@ module Assiette
19
15
 
20
16
  JS_EXTENSIONS = %w[.js .mjs].to_set.freeze
21
17
 
18
+ attr_reader :dependency_graph
19
+
22
20
  def initialize(root:, additional_directory_mappings: {})
23
21
  @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
22
+ @dependency_graph = DependencyGraph.new(self)
23
+ end
24
+
25
+ # Yields (url_path, abs_path) for every file with a recognized extension.
26
+ def each_mapped_file
27
+ @mappings.each do |prefix, root|
28
+ CONTENT_TYPES.each_key do |ext|
29
+ Dir[File.join(root, "**/*#{ext}")].each do |abs|
30
+ relative = Pathname.new(abs).relative_path_from(root).to_s
31
+ url_path = if prefix.empty?
32
+ relative
33
+ else
34
+ "#{prefix}/#{relative}"
35
+ end
36
+ yield url_path, abs
37
+ end
38
+ end
39
+ end
29
40
  end
30
41
 
31
42
  def resolve_file(path)
@@ -51,40 +62,25 @@ module Assiette
51
62
  def absolute_asset_url_path(path, script_name = "")
52
63
  clean = path.sub(%r{\A/}, "")
53
64
  return nil unless resolve_file(clean)
54
- "#{script_name}/#{clean}?v=#{Assiette.version_tag}"
65
+ hash = @dependency_graph.tree_sha(clean) || "00000000"
66
+ "#{script_name}/#{clean}?s=#{hash}"
55
67
  end
56
68
 
57
69
  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
70
+ clean = path.sub(%r{\A/}, "")
71
+ return nil unless resolve_file(clean)
72
+ @dependency_graph.tree_integrity(clean)
69
73
  end
70
74
 
71
75
  def js_modules
72
- version_tag = Assiette.version_tag
73
- @modules_mutex.synchronize do
74
- return @modules_cache if @modules_version == version_tag
75
-
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] }
84
-
85
- @modules_version = version_tag
86
- @modules_cache
87
- end
76
+ @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] }
88
84
  end
89
85
 
90
86
  private
@@ -97,19 +93,5 @@ module Assiette
97
93
  end
98
94
  mappings
99
95
  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
96
  end
115
97
  end
@@ -0,0 +1,310 @@
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
+ # Forces a full rebuild on next access.
89
+ def invalidate!
90
+ @mutex.synchronize do
91
+ @assets = {}
92
+ end
93
+ end
94
+
95
+ private
96
+
97
+ # Lazily resolve a single asset and its transitive dependencies.
98
+ # Returns the Asset or nil if the file doesn't exist.
99
+ def ensure_asset!(url_path)
100
+ @resolving = Set.new
101
+ @checked = Set.new
102
+ @cycle_groups = []
103
+ asset = resolve_asset!(url_path)
104
+ # Process any cycles detected during resolution
105
+ @cycle_groups.each { |scc| compute_cycle_digests(scc) }
106
+ @resolving = nil
107
+ @checked = nil
108
+ @cycle_groups = nil
109
+ asset
110
+ end
111
+
112
+ # Recursive resolution. Detects cycles via @resolving set.
113
+ # @checked prevents re-verifying the same asset within one ensure_asset! call.
114
+ # Returns the Asset or nil. Also returns whether anything changed downstream.
115
+ def resolve_asset!(url_path)
116
+ # Already verified fresh in this ensure_asset! call
117
+ return @assets[url_path] if @checked.include?(url_path)
118
+
119
+ existing = @assets[url_path]
120
+ if existing
121
+ if existing.deleted?
122
+ remove_asset!(url_path)
123
+ return nil
124
+ end
125
+
126
+ if existing.stale?
127
+ rescan_asset!(existing)
128
+ @checked << url_path
129
+ return existing
130
+ end
131
+
132
+ # Not stale itself — but deps might be. Check them recursively.
133
+ @checked << url_path
134
+ old_dep_digests = existing.deps.map { |dp| @assets[dp]&.digest }
135
+ existing.deps.each { |dp| resolve_asset!(dp) }
136
+ new_dep_digests = existing.deps.map { |dp| @assets[dp]&.digest }
137
+
138
+ if old_dep_digests != new_dep_digests
139
+ compute_digest_for(url_path)
140
+ end
141
+
142
+ return existing
143
+ end
144
+
145
+ # Resolve url_path to an absolute path
146
+ abs_path = @handler.resolve_file(url_path)
147
+ return nil unless abs_path
148
+
149
+ # Cycle detection
150
+ unless @resolving.add?(url_path)
151
+ return @assets[url_path]
152
+ end
153
+
154
+ # Create the asset node
155
+ asset = @assets[url_path] = Asset.new(
156
+ url_path: url_path,
157
+ abs_path: abs_path,
158
+ mtime: File.mtime(abs_path)
159
+ )
160
+ asset.deps = parse_deps(url_path, File.read(abs_path))
161
+
162
+ # Recursively resolve dependencies
163
+ in_cycle = false
164
+ asset.deps.each do |dep_path|
165
+ if @resolving.include?(dep_path) && !@assets[dep_path]&.digest
166
+ in_cycle = true
167
+ end
168
+ dep = resolve_asset!(dep_path)
169
+ dep.dependents << url_path if dep
170
+ end
171
+
172
+ if in_cycle || asset.deps.any? { |dp| @resolving.include?(dp) && !@assets[dp]&.digest }
173
+ scc = collect_cycle(url_path)
174
+ @cycle_groups << scc unless scc.empty?
175
+ else
176
+ compute_digest_for(url_path)
177
+ end
178
+
179
+ @checked << url_path
180
+ @resolving.delete(url_path)
181
+ asset
182
+ end
183
+
184
+ # Collect strongly connected component members starting from url_path.
185
+ def collect_cycle(url_path)
186
+ visited = Set.new
187
+ stack = [url_path]
188
+ members = Set.new
189
+
190
+ while (node = stack.pop)
191
+ next unless visited.add?(node)
192
+ asset = @assets[node]
193
+ next unless asset
194
+ members << node if @resolving.include?(node)
195
+ asset.deps.each do |dep|
196
+ stack << dep if @resolving.include?(dep)
197
+ end
198
+ end
199
+
200
+ members.to_a
201
+ end
202
+
203
+ # Re-read a stale asset, re-parse deps, recursively ensure deps fresh, recompute digest.
204
+ def rescan_asset!(asset)
205
+ url_path = asset.url_path
206
+ asset.mtime = File.mtime(asset.abs_path)
207
+
208
+ # Remove old reverse links
209
+ asset.deps.each do |dep_path|
210
+ dep = @assets[dep_path]
211
+ dep&.dependents&.delete(url_path)
212
+ end
213
+
214
+ # Re-parse imports
215
+ raw = File.read(asset.abs_path)
216
+ asset.deps = parse_deps(url_path, raw)
217
+
218
+ # Recursively ensure deps are fresh
219
+ asset.deps.each do |dep_path|
220
+ dep = resolve_asset!(dep_path)
221
+ dep.dependents << url_path if dep
222
+ end
223
+
224
+ # Recompute digest
225
+ compute_digest_for(url_path)
226
+
227
+ # Propagate to dependents already in the graph
228
+ propagate_to_dependents!(asset)
229
+ end
230
+
231
+ # Recompute digests for all transitive dependents of an asset.
232
+ def propagate_to_dependents!(asset)
233
+ queue = asset.dependents.to_a
234
+ visited = Set.new
235
+ while (dep_url = queue.shift)
236
+ next unless visited.add?(dep_url)
237
+ dep_asset = @assets[dep_url]
238
+ next unless dep_asset
239
+ compute_digest_for(dep_url)
240
+ queue.concat(dep_asset.dependents.to_a)
241
+ end
242
+ end
243
+
244
+ def parse_deps(url_path, raw)
245
+ ext = File.extname(url_path)
246
+ import_paths = case ext
247
+ when ".js", ".mjs", ".es" then Rewriter.extract_js_imports(raw)
248
+ when ".css" then Rewriter.extract_css_urls(raw)
249
+ else []
250
+ end
251
+ import_paths.map { |p| resolve_import_for(url_path, p) }
252
+ end
253
+
254
+ def compute_digest_for(url_path)
255
+ asset = @assets[url_path]
256
+ return unless asset
257
+
258
+ raw = File.read(asset.abs_path)
259
+ if asset.deps.empty?
260
+ asset.digest = Digest::SHA256.digest(raw)
261
+ else
262
+ rewritten = rewrite_content_internal(url_path, raw)
263
+ asset.digest = Digest::SHA256.digest(rewritten)
264
+ end
265
+ end
266
+
267
+ # For cyclic dependencies: hash all members' raw contents together.
268
+ def compute_cycle_digests(scc)
269
+ combined = scc.sort.filter_map { |url_path|
270
+ asset = @assets[url_path]
271
+ next unless asset
272
+ File.read(asset.abs_path)
273
+ }.join("\0")
274
+
275
+ digest = Digest::SHA256.digest(combined)
276
+ scc.each do |url_path|
277
+ asset = @assets[url_path]
278
+ asset.digest = digest if asset
279
+ end
280
+ end
281
+
282
+ def rewrite_content_internal(url_path, raw_content)
283
+ ext = File.extname(url_path)
284
+ case ext
285
+ when ".js", ".mjs", ".es"
286
+ Rewriter.rewrite_js_imports(raw_content) do |import_path|
287
+ resolved = resolve_import_for(url_path, import_path)
288
+ @assets[resolved]&.checksum_tag || "00000000"
289
+ end
290
+ when ".css"
291
+ Rewriter.rewrite_css_urls(raw_content) do |ref_path|
292
+ resolved = resolve_import_for(url_path, ref_path)
293
+ @assets[resolved]&.checksum_tag || "00000000"
294
+ end
295
+ else
296
+ raw_content
297
+ end
298
+ end
299
+
300
+ def remove_asset!(url_path)
301
+ asset = @assets.delete(url_path)
302
+ return unless asset
303
+
304
+ asset.deps.each do |dep_path|
305
+ dep = @assets[dep_path]
306
+ dep&.dependents&.delete(url_path)
307
+ end
308
+ end
309
+ end
310
+ 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
@@ -37,24 +37,17 @@ module Assiette
37
37
  file_path = @handler.resolve_file(path_info)
38
38
  return unless file_path
39
39
 
40
- raw_bytes = File.binread(file_path)
41
-
42
- # ETag from raw bytes for stability
43
- etag = %("#{Digest::SHA1.hexdigest(raw_bytes)}")
40
+ # Use the dependency graph's content hash for the ETag — it reflects
41
+ # the file's own content plus all its transitive dependencies, and is
42
+ # already computed as part of serving. This lets us short-circuit with
43
+ # a 304 before reading the file for rewriting.
44
+ etag = %("#{@handler.dependency_graph.tree_sha(path_info) || "0"}")
44
45
  if env["HTTP_IF_NONE_MATCH"] == etag
45
46
  return [304, {"etag" => etag, "cache-control" => CACHE_CONTROL}, []]
46
47
  end
47
48
 
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
49
+ raw_bytes = File.binread(file_path)
50
+ body = @handler.dependency_graph.rewrite_content(path_info, raw_bytes)
58
51
 
59
52
  headers = {
60
53
  "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.4.0"
5
5
  end
data/lib/assiette.rb CHANGED
@@ -5,6 +5,7 @@ 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__)
metadata CHANGED
@@ -1,7 +1,7 @@
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.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
@@ -35,13 +35,13 @@ files:
35
35
  - Rakefile
36
36
  - lib/assiette.rb
37
37
  - lib/assiette/asset_handler.rb
38
+ - lib/assiette/dependency_graph.rb
38
39
  - lib/assiette/helpers.rb
39
40
  - lib/assiette/rails_asset_url_helper.rb
40
41
  - lib/assiette/railtie.rb
41
42
  - lib/assiette/rewriter.rb
42
43
  - lib/assiette/server.rb
43
44
  - lib/assiette/version.rb
44
- - lib/assiette/version_tag.rb
45
45
  - lib/generators/assiette/install/install_generator.rb
46
46
  - lib/generators/assiette/install/templates/initializer.rb.tt
47
47
  homepage: https://github.com/julik/assiette
@@ -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