assiette 0.6.1 → 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: 0e320ec60ec5cb1469b38e4bc4bcfc707f1fa044e219e7aee6c94f7a01154e9f
4
- data.tar.gz: d73852fffc343121cc86b6e3d6cf1be17c3d9297eada8bc210c858409037345c
3
+ metadata.gz: e0f3b02555b0eb18e84813e36095d9fbd3528e9d7a61ed529e00c5b3dd6ecd5a
4
+ data.tar.gz: 20a0ec6ce64278f556608150ba66bf9429bef13f8bc45d41bc71e360f3f0151f
5
5
  SHA512:
6
- metadata.gz: 1c7591ac6ecc0e9390200a8e567b5fcc8c6c325b67784fe6521a5104f181f2639074cdb5dce319bef10961d94b1c21dc08dfec136fc6628da544b6ad10aec0d0
7
- data.tar.gz: cb7a98754dd1617e0d1cb18948ab5b81a8264a0c058fad121089d0b8480a84be64e77ff65b051e09bbc5fe030d9db85f246c54451710f600f32182f1ad8c6b92
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)
@@ -239,7 +246,7 @@ module Assiette
239
246
  end
240
247
 
241
248
  # Re-read a stale asset, re-parse deps, recursively ensure deps fresh, recompute digest.
242
- # Returns false if the file went away while we were looking at it.
249
+ # Returns true, or false if the file went away while we were looking at it.
243
250
  def rescan_asset!(asset)
244
251
  url_path = asset.url_path
245
252
  raw, mtime = read_or_drop!(asset)
@@ -265,6 +272,9 @@ module Assiette
265
272
 
266
273
  # Propagate to dependents already in the graph
267
274
  propagate_to_dependents!(asset)
275
+
276
+ # Not the walk's value: a `while` evaluates to nil, which reads as "gone"
277
+ true
268
278
  end
269
279
 
270
280
  # Recompute digests for all transitive dependents of an asset.
@@ -275,7 +285,7 @@ module Assiette
275
285
  # renamed away lingers in the graph as an importer, and the next edit to
276
286
  # anything it used to import tries to read a path that is not there. Its
277
287
  # own dependents still have to hear about it: they keep the vanished node
278
- # in their deps until they are rescanned, and it rewrites to 00000000 now.
288
+ # in their deps until they are rescanned, and it rewrites without ?s= now.
279
289
  def propagate_to_dependents!(asset)
280
290
  queue = asset.dependents.to_a
281
291
  visited = Set.new
@@ -322,6 +332,7 @@ module Assiette
322
332
 
323
333
  raw, _mtime = read_or_drop!(asset)
324
334
  return unless raw
335
+ asset.served_digest = nil
325
336
  if asset.deps.empty?
326
337
  asset.digest = Digest::SHA256.digest(raw)
327
338
  else
@@ -331,17 +342,19 @@ module Assiette
331
342
  end
332
343
 
333
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
334
347
  def compute_cycle_digests(scc)
335
- combined = scc.sort.filter_map { |url_path|
348
+ raws = scc.sort.each_with_object({}) do |url_path, acc|
336
349
  asset = @assets[url_path]
337
- next unless asset
338
- read_or_drop!(asset)&.first
339
- }.join("\0")
350
+ raw = asset && read_or_drop!(asset)&.first
351
+ acc[url_path] = raw if raw
352
+ end
340
353
 
341
- digest = Digest::SHA256.digest(combined)
342
- scc.each do |url_path|
343
- asset = @assets[url_path]
344
- 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))
345
358
  end
346
359
  end
347
360
 
@@ -349,14 +362,14 @@ module Assiette
349
362
  ext = File.extname(url_path)
350
363
  case ext
351
364
  when ".js", ".mjs", ".es"
352
- Rewriter.rewrite_js_imports(raw_content) do |import_path|
365
+ Rewriter.rewrite_js_imports(raw_content, from: url_path) do |import_path|
353
366
  resolved = resolve_import_for(url_path, import_path)
354
- @assets[resolved]&.checksum_tag || "00000000"
367
+ @assets[resolved]&.checksum_tag
355
368
  end
356
369
  when ".css"
357
370
  Rewriter.rewrite_css_urls(raw_content) do |ref_path|
358
371
  resolved = resolve_import_for(url_path, ref_path)
359
- @assets[resolved]&.checksum_tag || "00000000"
372
+ @assets[resolved]&.checksum_tag
360
373
  end
361
374
  else
362
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.1"
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,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: assiette
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.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-09-21 00:00:00.000000000 Z
11
+ date: 2026-11-08 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: actionpack
@@ -53,6 +54,7 @@ metadata:
53
54
  homepage_uri: https://github.com/julik/assiette
54
55
  source_code_uri: https://github.com/julik/assiette
55
56
  changelog_uri: https://github.com/julik/assiette/blob/main/CHANGELOG.md
57
+ post_install_message:
56
58
  rdoc_options: []
57
59
  require_paths:
58
60
  - lib
@@ -67,7 +69,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
69
  - !ruby/object:Gem::Version
68
70
  version: '0'
69
71
  requirements: []
70
- rubygems_version: 3.6.6
72
+ rubygems_version: 3.4.10
73
+ signing_key:
71
74
  specification_version: 4
72
75
  summary: Zero-build asset serving for Rails engines
73
76
  test_files: []