assiette 0.6.0 → 0.7.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: b94d7ebde46d04a55abfd53f02d95c2eb6b4d4df1bda833c98089afbefd1ab1b
4
- data.tar.gz: 342adfcac7ade1a593af2f887883314bd35d1ccae4e87f91e503c0008e8db46a
3
+ metadata.gz: e0f3b02555b0eb18e84813e36095d9fbd3528e9d7a61ed529e00c5b3dd6ecd5a
4
+ data.tar.gz: 20a0ec6ce64278f556608150ba66bf9429bef13f8bc45d41bc71e360f3f0151f
5
5
  SHA512:
6
- metadata.gz: 26ede00337abc21c2ee238e0e99d7d2143d61cbae84eac72d22210f16cd373c0a41056a9edad881b6a6f049acb353e96605cde191238b6ea643998a4cb255db9
7
- data.tar.gz: 7c5507d623a0cc264ecbd8f5776a71aef7ad001753d1117c9e0f8cced9fcbc67542a447d4faca3152d89109cea5e2175fc1a72d564aff1cfc22c2f5162e63361
6
+ metadata.gz: f6e01429228aefba47fc36242f8180b074c58c65297ed490ac6b94562988396d1a375acb8b6e31c6a3514f009960f5caf0dc7ffb56dea75d2247886aa89044ef
7
+ data.tar.gz: 5094d53275d9d5fc760f97c24905a840d55254e768aebfd979928d9afcb8c014819e443ee7e658600dd70aaa26825dcf4b95733581817cc7a9e98b33e91b7fc2
@@ -22,6 +22,9 @@ module Assiette
22
22
 
23
23
  JS_EXTENSIONS = %w[.js .mjs].to_set.freeze
24
24
 
25
+ # What #expose takes as a handle: "@" and a name, as in "@task_progress"
26
+ HANDLE_RE = /\A@[a-z0-9][\w.-]*\z/i
27
+
25
28
  attr_reader :dependency_graph
26
29
 
27
30
  # Every extension this handler serves, as ".ext" => content type. This is
@@ -42,9 +45,46 @@ module Assiette
42
45
  def initialize(root:, additional_directory_mappings: {}, content_types: {})
43
46
  @content_types = CONTENT_TYPES.merge(content_types.to_h { |ext, type| [normalize_extension(ext), type] }).freeze
44
47
  @mappings = build_mappings(root, additional_directory_mappings)
48
+ @exposed = {}
45
49
  @dependency_graph = DependencyGraph.new(self)
46
50
  end
47
51
 
52
+ # Makes a directory - a gem's, say - available as "@handle/...". Its files
53
+ # are served at /@handle/..., and an import of "@handle/file.js" from any
54
+ # module this handler serves is rewritten to that file, fingerprinted:
55
+ #
56
+ # handler.expose("@task_progress", TaskProgress.js_path)
57
+ # import { followProgress } from "@task_progress/task_tree.js"
58
+ #
59
+ # Several directories under one handle are searched in the order given, and
60
+ # the root still comes first, so the app's own app/assets/@handle/file.js
61
+ # wins over the one exposed.
62
+ #
63
+ # Exposing a handle again with the same directories does nothing - an
64
+ # initializer that runs twice is the likely cause, and harmless. With other
65
+ # directories, the new ones replace the old, with a warning.
66
+ #
67
+ # Call it while setting up, before the handler serves anything.
68
+ def expose(handle, directories)
69
+ handle = handle.to_s
70
+ raise ArgumentError, "#{handle.inspect} is not a handle - it needs to look like \"@name\"" unless handle.match?(HANDLE_RE)
71
+ roots = Array(directories).map { |dir| Pathname.new(dir).expand_path }
72
+ return self if @exposed[handle] == roots
73
+ if @exposed.key?(handle)
74
+ warn "Assiette: #{handle} was exposed from #{@exposed[handle].join(", ")}, and now from #{roots.join(", ")} instead"
75
+ end
76
+ @exposed[handle] = roots
77
+ # A new array rather than changing this one, so a thread walking it is not disturbed
78
+ @mappings = @mappings.reject { |prefix, _root| prefix == handle } + roots.map { |root| [handle, root] }
79
+ @watched_directories = nil
80
+ self
81
+ end
82
+
83
+ # The exposed handles, each with the directories behind it
84
+ def exposed
85
+ @exposed.dup
86
+ end
87
+
48
88
  # The content type this handler serves `path` as, or nil if its extension
49
89
  # is not one this handler knows.
50
90
  def content_type_for(path)
@@ -91,8 +131,9 @@ module Assiette
91
131
  def absolute_asset_url_path(path, script_name = "")
92
132
  clean = path.sub(%r{\A/}, "")
93
133
  return nil unless resolve_file(clean)
94
- hash = @dependency_graph.tree_sha(clean) || "00000000"
95
- "#{script_name}/#{clean}?s=#{hash}"
134
+ # No digest means the file went away mid-call. A constant tag would get cached like a real version
135
+ hash = @dependency_graph.tree_sha(clean)
136
+ hash ? "#{script_name}/#{clean}?s=#{hash}" : "#{script_name}/#{clean}"
96
137
  end
97
138
 
98
139
  def asset_integrity(path)
@@ -101,16 +142,37 @@ module Assiette
101
142
  @dependency_graph.tree_integrity(clean)
102
143
  end
103
144
 
104
- def js_modules
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] }
145
+ # The modules to preload: every module under the handler's own roots, and
146
+ # of the exposed directories only what those modules reach through their
147
+ # imports. A static import and a literal `import("...")` count alike, since
148
+ # the graph records both, so the browser is handed the whole tree up front
149
+ # and discovers nothing on its own. An app that imports one file of a gem
150
+ # preloads that file and what it pulls in, and none of the gem's other
151
+ # modules.
152
+ #
153
+ # `exposed:` changes what the exposed directories contribute:
154
+ #
155
+ # :imported what the own modules reach (the default)
156
+ # :all every module of every exposed directory
157
+ # :none, [] nothing
158
+ # "@gem", ["@a", ..] every module of those handles, and in the other
159
+ # handles still what is reached - for an app whose
160
+ # `import()` of a gem file is computed at run time,
161
+ # which no scan can see
162
+ def js_modules(exposed: :imported)
163
+ own_mappings, exposed_mappings = @mappings.partition { |prefix, _root| !@exposed.key?(prefix) }
164
+ own = modules_under(own_mappings)
165
+ whole_handles, follow_imports = case exposed
166
+ when :imported then [[], true]
167
+ when :all, true then [@exposed.keys, false]
168
+ when :none, nil then [[], false]
169
+ else
170
+ handles = Array(exposed).map(&:to_s)
171
+ [handles, handles.any?]
172
+ end
173
+ listed = modules_under(exposed_mappings.select { |mapping| whole_handles.include?(mapping.first) })
174
+ reached = follow_imports ? exposed_modules_imported_by(own) : []
175
+ (own + listed + reached).uniq { |m| m[:path] }.sort_by { |m| m[:path] }
114
176
  end
115
177
 
116
178
  # A single 16-char hex hash covering every asset this handler can serve.
@@ -154,6 +216,43 @@ module Assiette
154
216
 
155
217
  private
156
218
 
219
+ # Every file under `mappings` with a JavaScript extension that imports or
220
+ # exports something, as {path:, integrity:}
221
+ def modules_under(mappings)
222
+ js_glob = "**/*{#{javascript_extensions.map { |ext| case_insensitive(ext) }.join(",")}}"
223
+ mappings.flat_map { |prefix, root|
224
+ Dir.glob(File.join(root, js_glob)).filter_map { |abs|
225
+ next unless File.foreach(abs).any? { |line| line.match?(/\A\s*(import|export)\s/) }
226
+ relative = Pathname.new(abs).relative_path_from(root).to_s
227
+ mod_path = "/#{"#{prefix}/" unless prefix.empty?}#{relative}".squeeze("/")
228
+ {path: mod_path, integrity: asset_integrity(mod_path)}
229
+ }
230
+ }
231
+ end
232
+
233
+ # The exposed JavaScript modules reached from `modules` by walking the
234
+ # graph's deps. Listing `modules` resolved each of them already, so the
235
+ # walk is over nodes that are in memory, with their mtimes checked.
236
+ def exposed_modules_imported_by(modules)
237
+ seen = Set.new
238
+ queue = modules.map { |m| m[:path].delete_prefix("/") }
239
+ found = []
240
+ until queue.empty?
241
+ url_path = queue.shift
242
+ next unless seen.add?(url_path)
243
+ asset = @dependency_graph[url_path]
244
+ next unless asset
245
+ found << {path: "/#{url_path}", integrity: asset.sri_integrity} if exposed_javascript?(url_path)
246
+ queue.concat(asset.deps)
247
+ end
248
+ found
249
+ end
250
+
251
+ def exposed_javascript?(url_path)
252
+ @exposed.each_key.any? { |handle| url_path.start_with?("#{handle}/") } &&
253
+ javascript_extensions.include?(normalize_extension(File.extname(url_path)))
254
+ end
255
+
157
256
  # The extensions served as JavaScript: .js and .mjs, plus anything this
158
257
  # handler had registered with a JavaScript content type.
159
258
  def javascript_extensions
@@ -10,12 +10,15 @@ module Assiette
10
10
  class Asset
11
11
  attr_reader :url_path
12
12
  attr_accessor :abs_path, :mtime, :digest, :deps, :dependents
13
+ # Set only for cycle members, whose served bytes differ from what the shared digest covers
14
+ attr_accessor :served_digest
13
15
 
14
16
  def initialize(url_path:, abs_path:, mtime:)
15
17
  @url_path = url_path.freeze
16
18
  @abs_path = abs_path
17
19
  @mtime = mtime
18
20
  @digest = nil
21
+ @served_digest = nil
19
22
  @deps = []
20
23
  @dependents = Set.new
21
24
  end
@@ -27,8 +30,9 @@ module Assiette
27
30
 
28
31
  # SRI integrity string for integrity= attributes.
29
32
  def sri_integrity
30
- return nil unless digest
31
- "sha256-#{Base64.strict_encode64(digest)}"
33
+ sri_digest = served_digest || digest
34
+ return nil unless sri_digest
35
+ "sha256-#{Base64.strict_encode64(sri_digest)}"
32
36
  end
33
37
 
34
38
  # Whether this asset's file has changed since last scan.
@@ -67,9 +71,12 @@ module Assiette
67
71
  self[url_path]&.sri_integrity
68
72
  end
69
73
 
70
- # Resolves a relative import path to a URL path.
74
+ # Resolves a relative import path to a URL path. An "@handle/..." path is
75
+ # already one - see AssetHandler#expose
71
76
  def resolve_import_for(from_url_path, relative_path)
72
- if relative_path.start_with?("/")
77
+ if relative_path.start_with?("@")
78
+ relative_path
79
+ elsif relative_path.start_with?("/")
73
80
  relative_path.sub(%r{\A/}, "")
74
81
  else
75
82
  dir = File.dirname(from_url_path)
@@ -155,7 +162,7 @@ module Assiette
155
162
  end
156
163
 
157
164
  if existing.stale?
158
- rescan_asset!(existing)
165
+ return nil unless rescan_asset!(existing)
159
166
  @checked << url_path
160
167
  return existing
161
168
  end
@@ -239,9 +246,12 @@ module Assiette
239
246
  end
240
247
 
241
248
  # Re-read a stale asset, re-parse deps, recursively ensure deps fresh, recompute digest.
249
+ # Returns true, or false if the file went away while we were looking at it.
242
250
  def rescan_asset!(asset)
243
251
  url_path = asset.url_path
244
- asset.mtime = File.mtime(asset.abs_path)
252
+ raw, mtime = read_or_drop!(asset)
253
+ return false unless raw
254
+ asset.mtime = mtime
245
255
 
246
256
  # Remove old reverse links
247
257
  asset.deps.each do |dep_path|
@@ -249,8 +259,6 @@ module Assiette
249
259
  dep&.dependents&.delete(url_path)
250
260
  end
251
261
 
252
- # Re-parse imports
253
- raw = File.read(asset.abs_path)
254
262
  asset.deps = parse_deps(url_path, raw)
255
263
 
256
264
  # Recursively ensure deps are fresh
@@ -264,9 +272,20 @@ module Assiette
264
272
 
265
273
  # Propagate to dependents already in the graph
266
274
  propagate_to_dependents!(asset)
275
+
276
+ # Not the walk's value: a `while` evaluates to nil, which reads as "gone"
277
+ true
267
278
  end
268
279
 
269
280
  # Recompute digests for all transitive dependents of an asset.
281
+ #
282
+ # A dependent whose own file has since gone is dropped rather than
283
+ # recomputed. Nothing ever resolves an orphan from above — it is only
284
+ # reached from below, through this very walk — so without this a file
285
+ # renamed away lingers in the graph as an importer, and the next edit to
286
+ # anything it used to import tries to read a path that is not there. Its
287
+ # own dependents still have to hear about it: they keep the vanished node
288
+ # in their deps until they are rescanned, and it rewrites without ?s= now.
270
289
  def propagate_to_dependents!(asset)
271
290
  queue = asset.dependents.to_a
272
291
  visited = Set.new
@@ -274,8 +293,12 @@ module Assiette
274
293
  next unless visited.add?(dep_url)
275
294
  dep_asset = @assets[dep_url]
276
295
  next unless dep_asset
277
- compute_digest_for(dep_url)
278
296
  queue.concat(dep_asset.dependents.to_a)
297
+ if dep_asset.deleted?
298
+ remove_asset!(dep_url)
299
+ else
300
+ compute_digest_for(dep_url)
301
+ end
279
302
  end
280
303
  end
281
304
 
@@ -289,11 +312,27 @@ module Assiette
289
312
  import_paths.map { |p| resolve_import_for(url_path, p) }
290
313
  end
291
314
 
315
+ # Reads an asset's bytes, together with the mtime they were read at, and
316
+ # returns nil after dropping the node if the file is not there any more.
317
+ #
318
+ # Every read of a path the graph remembers goes through here. Those paths
319
+ # come from an earlier request, and while the server is up any of them can
320
+ # be renamed or deleted between two renders — a disappearance has to read
321
+ # as "gone from the graph", never as an exception out of a view helper.
322
+ def read_or_drop!(asset)
323
+ [File.read(asset.abs_path), File.mtime(asset.abs_path)]
324
+ rescue Errno::ENOENT
325
+ remove_asset!(asset.url_path)
326
+ nil
327
+ end
328
+
292
329
  def compute_digest_for(url_path)
293
330
  asset = @assets[url_path]
294
331
  return unless asset
295
332
 
296
- raw = File.read(asset.abs_path)
333
+ raw, _mtime = read_or_drop!(asset)
334
+ return unless raw
335
+ asset.served_digest = nil
297
336
  if asset.deps.empty?
298
337
  asset.digest = Digest::SHA256.digest(raw)
299
338
  else
@@ -303,17 +342,19 @@ module Assiette
303
342
  end
304
343
 
305
344
  # For cyclic dependencies: hash all members' raw contents together.
345
+ # That shared digest only fingerprints - each member is served rewritten with
346
+ # its partners' ?s=, so SRI has to be taken over those bytes or the browser refuses it
306
347
  def compute_cycle_digests(scc)
307
- combined = scc.sort.filter_map { |url_path|
348
+ raws = scc.sort.each_with_object({}) do |url_path, acc|
308
349
  asset = @assets[url_path]
309
- next unless asset
310
- File.read(asset.abs_path)
311
- }.join("\0")
350
+ raw = asset && read_or_drop!(asset)&.first
351
+ acc[url_path] = raw if raw
352
+ end
312
353
 
313
- digest = Digest::SHA256.digest(combined)
314
- scc.each do |url_path|
315
- asset = @assets[url_path]
316
- asset.digest = digest if asset
354
+ digest = Digest::SHA256.digest(raws.values.join("\0"))
355
+ raws.each_key { |url_path| @assets[url_path].digest = digest }
356
+ raws.each do |url_path, raw|
357
+ @assets[url_path].served_digest = Digest::SHA256.digest(rewrite_content_internal(url_path, raw))
317
358
  end
318
359
  end
319
360
 
@@ -321,14 +362,14 @@ module Assiette
321
362
  ext = File.extname(url_path)
322
363
  case ext
323
364
  when ".js", ".mjs", ".es"
324
- Rewriter.rewrite_js_imports(raw_content) do |import_path|
365
+ Rewriter.rewrite_js_imports(raw_content, from: url_path) do |import_path|
325
366
  resolved = resolve_import_for(url_path, import_path)
326
- @assets[resolved]&.checksum_tag || "00000000"
367
+ @assets[resolved]&.checksum_tag
327
368
  end
328
369
  when ".css"
329
370
  Rewriter.rewrite_css_urls(raw_content) do |ref_path|
330
371
  resolved = resolve_import_for(url_path, ref_path)
331
- @assets[resolved]&.checksum_tag || "00000000"
372
+ @assets[resolved]&.checksum_tag
332
373
  end
333
374
  else
334
375
  raw_content
@@ -32,10 +32,17 @@ module Assiette
32
32
  # innermost Server in the stack — on purpose. It renders a listing of
33
33
  # everything a handler holds, so walking the stack would put one tenant's
34
34
  # file list on another tenant's page.
35
- def assiette_modulepreload_tags
35
+ #
36
+ # Of the exposed directories (AssetHandler#expose) it lists what the app's
37
+ # own modules import, directly or down the chain, and nothing else: the
38
+ # import is the selection. `exposed: "@my_gem"` lists all of that handle
39
+ # instead, for an `import()` the app computes at run time; `exposed: :all`
40
+ # lists every exposed module and `exposed: []` none. See
41
+ # AssetHandler#js_modules.
42
+ def assiette_modulepreload_tags(exposed: :imported)
36
43
  entry = assiette_stack.last
37
44
  handler = entry[:handler]
38
- safe_join(handler.js_modules.map { |mod|
45
+ safe_join(handler.js_modules(exposed: exposed).map { |mod|
39
46
  tag.link(rel: "modulepreload",
40
47
  href: handler.absolute_asset_url_path(mod[:path], entry[:script_name]),
41
48
  integrity: mod[:integrity], crossorigin: "anonymous")
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pathname"
4
+
3
5
  module Assiette
4
6
  module Rewriter
5
7
  # Matches quoted strings that look like relative/absolute JS import paths.
6
- # Handles ./ ../ / but NOT protocol-relative //
8
+ # Handles ./ ../ / but NOT protocol-relative //, and "@handle/..." for a
9
+ # directory exposed with AssetHandler#expose
7
10
  # $1 = quote char, $2 = path including extension
8
- JS_IMPORT_RE = /(["'])(\.{0,2}\/(?!\/)[^"']*\.(?:js|mjs|es))\1/
11
+ JS_IMPORT_RE = /(["'])((?:\.{0,2}\/(?!\/)|@[\w.-]+\/)[^"']*\.(?:js|mjs|es))\1/
9
12
 
10
13
  # Matches url() in CSS with relative/absolute paths.
11
14
  # Handles url(./path), url("./path"), url('../path'), url(/path)
@@ -16,25 +19,42 @@ module Assiette
16
19
  module_function
17
20
 
18
21
  # 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)
22
+ # The block receives the import path and returns the hash for that import,
23
+ # or nil to leave the import unversioned.
24
+ #
25
+ # An "@handle/..." import that gets a hash is also turned into a path
26
+ # relative to `from` - the URL path of the file being rewritten - since a
27
+ # browser cannot resolve a bare "@handle" without an import map. Relative,
28
+ # so it keeps working under whatever SCRIPT_NAME the handler is mounted at.
29
+ # One that gets no hash is left as written, for an import map to resolve.
30
+ def rewrite_js_imports(source, from: "", &block)
21
31
  source.gsub(JS_IMPORT_RE) do
22
32
  quote = $1
23
33
  path = $2
24
34
  hash = yield(path)
35
+ next "#{quote}#{path}#{quote}" unless hash
36
+ path = relative_to(from, path) if path.start_with?("@")
25
37
  "#{quote}#{path}?s=#{hash}#{quote}"
26
38
  end
27
39
  end
28
40
 
41
+ # "@x/y.js" as seen from the directory of `from`, always starting with ./
42
+ # or ../ - "y.js" alone would be a bare specifier again
43
+ def relative_to(from, path)
44
+ relative = Pathname.new(path).relative_path_from(Pathname.new(File.dirname(from))).to_s
45
+ relative.start_with?("../") ? relative : "./#{relative}"
46
+ end
47
+
29
48
  # 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.
49
+ # The block receives the url path and returns the hash for that url,
50
+ # or nil to leave the url unversioned.
31
51
  def rewrite_css_urls(source, &block)
32
52
  source.gsub(CSS_URL_RE) do
33
53
  open = $1
34
54
  path = $2
35
55
  close = $3
36
56
  hash = yield(path)
37
- "url(#{open}#{path}?s=#{hash}#{close})"
57
+ hash ? "url(#{open}#{path}?s=#{hash}#{close})" : "url(#{open}#{path}#{close})"
38
58
  end
39
59
  end
40
60
 
@@ -18,6 +18,10 @@ module Assiette
18
18
  def initialize(app, handler = nil, root: nil, additional_directory_mappings: {})
19
19
  @app = app
20
20
  @handler = handler || AssetHandler.new(root: root, additional_directory_mappings: additional_directory_mappings)
21
+ # In Rails a gem exposes its directory from ActiveSupport.on_load(:assiette), which runs on
22
+ # every handler a Server is built with - whenever the gem's block was registered. A handler
23
+ # made per request is not announced: the hooks keep every handler they ran on, for ever
24
+ ActiveSupport.run_load_hooks(:assiette, @handler) if defined?(Assiette::Railtie) && @handler.is_a?(AssetHandler)
21
25
  end
22
26
 
23
27
  def call(env)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Assiette
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.0"
5
5
  end
@@ -16,6 +16,10 @@ handler = Assiette::AssetHandler.new(
16
16
  }
17
17
  )
18
18
 
19
+ # Gems that ship JS or CSS for Assiette expose their directories on this
20
+ # handler themselves, as "@some_gem" - import "@some_gem/file.js" from your own
21
+ # modules. Add your own with handler.expose("@name", path).
22
+
19
23
  # Serve files through the shared handler.
20
24
  Rails.application.config.middleware.use Assiette::Server, handler
21
25
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: assiette
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julik Tarkhanov
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-09-13 00:00:00.000000000 Z
11
+ date: 2026-11-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actionpack