importmap-plus 2.0.2 → 2.1.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: '068b0fd351d34f6741037740731f5545ec5e98530ddd935abc45d18ac92b30e1'
4
- data.tar.gz: 6482173bb6db3322fa43711fce8ccf0c9ac48d7b35c0b572e5b68d45f11d0445
3
+ metadata.gz: 79dc17549a50164758c2fe012056e4c64ca1290f09d5ef9a6c9c52b5df64b3c7
4
+ data.tar.gz: 3c4eddba8ee4c5c95a898dd5ebb2a50a2f34e4c7022d03cbbdb2d515c13a9589
5
5
  SHA512:
6
- metadata.gz: 22b1de80057a17f315ab8230d5537400985c9f67c1c50faa184336856de9e3e1d8f06ea815392c78a0fef30bffa3373bd25b206ab638cf056c8447faa674fe44
7
- data.tar.gz: 444fca5f51ec92e43ab7163bb14ce92791727b3a93242298e776a753cc243e218f17fdf3acb6c873842012bf38867862fa37bf4eb99e0464ed8e72061254c33f
6
+ metadata.gz: 1a84cb54c15c6bc465d3f2a5c3bf3f337a28601ea14f3d402b6894e1cf128173d14995ea0c0968bfb5851128f38b502003a902db25ab479e1ee75a5c521de17d
7
+ data.tar.gz: c0a778797572cba636a5c3cb90049393fb5ba5229cd8dc01222ed932cd840e664e9a448c312575cf3937e64148322fb12754c4dd3afee3b8cb9aa7aa6603bd39
data/CHANGELOG.md CHANGED
@@ -1,6 +1,62 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 2.1.0
4
+
5
+ ### Added
6
+
7
+ - **Every app's map is finalized once it has booted, in every environment.**
8
+ After initialization the engine calls the new `Importmap::Map#finalize!`:
9
+ every pin and directory is deeply frozen, Ractor-shareable on Ruby 3+ (but
10
+ for a pinned value no Ractor can share, below), and so is every set of pins
11
+ the map publishes after it. A development reload, the
12
+ cache sweeper, and a `pin` or `draw` after boot work as before, each
13
+ publishing a new, frozen set, so nothing that pins or reloads starts raising.
14
+ `Importmap::Map#finalized?` says whether a map has been. Outside the engine
15
+ (an `Importmap::Map.new` passed to the helpers) `finalize!` is yours to call.
16
+
17
+ ### Changed
18
+
19
+ - **Behaviour change: the pins are frozen once the app has booted.** Changing
20
+ one in place, such as `importmap.packages["md5"].preload = false`, raises
21
+ `FrozenError` in development and test as well as production. Change the map
22
+ with `pin`, `pin_all_from` and `draw`; an app that can't sets
23
+ `config.importmap.finalize = false`, which leaves its pins unfrozen.
24
+ - **A pinned value no Ractor can share is kept as it is, never raised on.** A
25
+ pin whose `to:` is an object holding a lock, a logger or a lambda over the
26
+ app's state stays the object the app gave, unfrozen, and works on the main
27
+ Ractor only; its pin and every other pin are still frozen. The same goes for
28
+ the class-level URIs below.
29
+ - **`Importmap::Map#packages` and `#directories` return frozen hashes**, the
30
+ current generation's (see the reload fix below). Change the map with `pin`,
31
+ `pin_all_from` and `draw`.
32
+ - **The class-level URIs are stored deeply frozen.** `Packager.endpoint`,
33
+ `Packager.esm_run_resolver` (`EsmRun.resolver`) and `Npm.base_uri` are set
34
+ once, in an initializer; the URI you assign is frozen in place, so no thread
35
+ can change it under another. The default minifier is looked for when
36
+ `importmap/packager` loads rather than on first use; one you set, a lambda
37
+ over your own state included, is kept as given, and `nil` puts the default
38
+ back.
39
+ - Every Ruby file in the gem has `# frozen_string_literal: true`, but for the
40
+ two install templates copied into an app (`bin/importmap` and
41
+ `config/importmap.rb`), which are the app's files.
42
+
43
+ ### Fixed
44
+
45
+ - **A request during a reload renders a whole map.** The reloader
46
+ re-evaluated `config/importmap.rb` into the map every request reads, one pin
47
+ at a time, so a request mid-reload could render half of the new map and half
48
+ of the old. Every change now builds the next pin set on a copy and swaps it
49
+ in whole, and a draw that raises leaves the map it started from. A reload
50
+ still adds to the map, as before.
51
+
52
+ - **A render no longer caches the map from before a change.** The rendered
53
+ JSON and preloads were memoised with an unlocked check-then-set that a pin,
54
+ `enable_integrity!` or the cache sweeper reset underneath: a render that
55
+ started first finished after the reset and cached the old map until the next
56
+ change. Each render is now cached in the pin set it read, under a lock, and
57
+ renders that miss together all get the one that was kept.
58
+
59
+ ## 2.0.2
4
60
 
5
61
  ### Fixed
6
62
 
data/README.md CHANGED
@@ -64,7 +64,7 @@ bundle exec rake test # the command tests talk to live CDNs (jspm, jsDe
64
64
  cd docs && bin/dev # the docs site, a docs-kit app at docs/
65
65
  ```
66
66
 
67
- Releases are cut with `bin/release`, which tags a version and publishes a GitHub Release; that fires `.github/workflows/release.yml` (RubyGems trusted publishing) and `.github/workflows/deploy-docs.yml` (the docs site).
67
+ Releases are cut with `bin/release` (the zoolutions release kit), which bumps the version and lockfile pins, pushes `main` and publishes a GitHub Release; that fires `.github/workflows/release.yml` (RubyGems trusted publishing) and `.github/workflows/deploy-docs.yml` (the docs site).
68
68
 
69
69
  ## License
70
70
 
data/Rakefile CHANGED
@@ -1,9 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "bundler/setup"
2
4
 
3
5
  APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
6
  load "rails/tasks/engine.rake"
5
7
 
6
- require "bundler/gem_tasks"
8
+ # No `bundler/gem_tasks`: its `release` task (tag + `gem push` from this machine)
9
+ # would merge into the release kit's `rake release[X.Y.Z]` (rakelib/release.rake)
10
+ # and publish with a local API key. Releases go through bin/release; the gem is
11
+ # built and pushed by release.yml over trusted publishing.
7
12
 
8
13
  require "rake/testtask"
9
14
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Importmap::Freshness
2
4
  def stale_when_importmap_changes
3
5
  etag { Rails.application.importmap.digest(resolver: helpers) if request.format&.html? }
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Importmap::ImportmapTagsHelper
2
4
  # Setup all script tags needed to use an importmap-powered entrypoint (which defaults to application.js)
3
5
  def javascript_importmap_tags(entry_point = "application", importmap: Rails.application.importmap)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "importmap/provider_chain"
2
4
 
3
5
  # Resolves a group of specs against a CDN, batch first and then one at a time.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "thor"
2
4
  require "importmap/packager"
3
5
  require "importmap/package_graph"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "net/http"
2
4
  require "uri"
3
5
  require "pathname"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The modulepreload links of javascript_importmap_tags, sent ahead of the
2
4
  # response as 103 Early Hints.
3
5
  #
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "importmap/map"
2
4
  require "importmap/early_hints"
3
5
 
@@ -13,6 +15,7 @@ module Importmap
13
15
  config.importmap.rescuable_asset_errors = []
14
16
  config.importmap.preload_strategy = :all
15
17
  config.importmap.early_hints = true
18
+ config.importmap.finalize = true
16
19
 
17
20
  config.autoload_once_paths = %W( #{root}/app/helpers #{root}/app/controllers )
18
21
 
@@ -72,5 +75,17 @@ module Importmap
72
75
  app.config.importmap.rescuable_asset_errors << Sprockets::Rails::Helper::AssetNotFound
73
76
  end
74
77
  end
78
+
79
+ # Once the app has booted, in every environment, its map is finalized:
80
+ # every pin is deeply frozen (Ractor-shareable), and so is every set of
81
+ # pins a development reload, a cache sweep or a later pin publishes, so
82
+ # reloading works as before. Registered from an initializer, so it runs
83
+ # after the after_initialize blocks of config/application.rb and the
84
+ # environment files.
85
+ initializer "importmap.finalize" do |app|
86
+ app.config.after_initialize do
87
+ app.importmap.finalize! if app.config.importmap.finalize
88
+ end
89
+ end
75
90
  end
76
91
  end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "uri"
2
4
  require "json"
5
+ require "importmap/shareable"
3
6
 
4
7
  # Everything this gem knows about jsDelivr's bundling endpoint
5
8
  # (https://www.jsdelivr.com/esm): one minified ESM file per package, with its
@@ -32,8 +35,14 @@ class Importmap::EsmRun
32
35
 
33
36
  # The jsDelivr data API versions are resolved through. Also readable and
34
37
  # writable as Importmap::Packager.esm_run_resolver, which is where an app
35
- # that points this at a mirror has always set it.
36
- singleton_class.attr_accessor :resolver
38
+ # that points this at a mirror has always set it, in an initializer.
39
+ singleton_class.attr_reader :resolver
40
+
41
+ def self.resolver=(value)
42
+ # A boot-time setting, written before the commands that read it, and
43
+ # stored deeply frozen (Ractor-shareable), so none can change it in place.
44
+ @resolver = Importmap::Shareable.make(value) # rubocop:disable ThreadSafety/ClassInstanceVariable
45
+ end
37
46
  self.resolver = URI("https://data.jsdelivr.com/v1/packages/npm/")
38
47
 
39
48
  class << self
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "pathname"
2
4
  require "set"
3
5
  require "importmap/import_scanner"
@@ -24,9 +26,16 @@ class Importmap::Graph
24
26
  # requiring the CLI onto the request path to borrow it would be a poor trade.
25
27
  REMOTE_PATH_REGEXP = %r{\A(?:[a-zA-Z][a-zA-Z0-9+\-.]*:)?//}.freeze # :nodoc:
26
28
 
27
- def initialize(map, roots: [])
28
- @map = map
29
- @roots = Array(roots).map { |root| Pathname.new(root) }
29
+ # +entries+ are the expanded pins to walk when given (Importmap::Map passes
30
+ # those of the generation it is rendering), +map+'s otherwise. One graph
31
+ # serves every render of a generation, so its file reads are memoised under
32
+ # a lock.
33
+ def initialize(map, roots: [], entries: nil)
34
+ @roots = Array(roots).map { |root| Pathname.new(root) }
35
+ @entries = (entries || map.each_expanded_package).to_h
36
+ @keys_by_path = @entries.each_with_object({}) { |(key, mapping), keys| keys[mapping.path] ||= key }
37
+ @edges = {}
38
+ @lock = Mutex.new
30
39
  end
31
40
 
32
41
  # The keys reachable from +entry_points+ through static imports, the entry
@@ -45,21 +54,21 @@ class Importmap::Graph
45
54
  end
46
55
 
47
56
  private
48
- def entries
49
- @entries ||= @map.each_expanded_package.to_h
50
- end
57
+ attr_reader :entries
51
58
 
52
59
  # The key a path belongs to, for resolving a relative import back into the
53
60
  # map. Two keys can name one file; the first one drawn wins, and since both
54
61
  # resolve to the same asset path the preload set can't tell them apart
55
62
  # anyway.
56
- def keys_by_path
57
- @keys_by_path ||= entries.each_with_object({}) { |(key, mapping), keys| keys[mapping.path] ||= key }
58
- end
63
+ attr_reader :keys_by_path
59
64
 
65
+ # The file is read outside the lock; the first render to store its edges
66
+ # wins.
60
67
  def edges_from(key)
61
- @edges ||= {}
62
- @edges[key] ||= compute_edges(entries[key])
68
+ @lock.synchronize { @edges[key] } || begin
69
+ edges = compute_edges(entries[key])
70
+ @lock.synchronize { @edges[key] ||= edges }
71
+ end
63
72
  end
64
73
 
65
74
  def compute_edges(mapping)
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "net/http"
2
4
  require "openssl"
3
5
 
@@ -9,6 +11,9 @@ module Importmap::HttpRetries
9
11
  RETRYABLE_ERRORS = [ SocketError, SystemCallError, Timeout::Error, EOFError, OpenSSL::SSL::SSLError, Net::ProtocolError ].freeze # :nodoc:
10
12
  RETRYABLE_CODES = %w[ 429 500 502 503 504 ].freeze # :nodoc:
11
13
 
14
+ # Boot-time settings, also written as Packager.retry_attempts and
15
+ # retry_wait. The defaults are an Integer and a Float, so shareable; a
16
+ # value an app writes is kept as given.
12
17
  singleton_class.attr_accessor :attempts, :wait
13
18
  self.attempts = 3
14
19
  self.wait = 0.5
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "importmap/module_inspector"
2
4
 
3
5
  # Every module specifier a JavaScript file names, in source order, with whether
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "digest"
2
4
 
3
5
  # The subresource-integrity hash of a file, in the form the import map's
data/lib/importmap/map.rb CHANGED
@@ -1,8 +1,14 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "pathname"
2
4
  require "importmap/graph"
5
+ require "importmap/pin_set"
3
6
 
4
7
  class Importmap::Map
5
- attr_reader :packages, :directories
8
+ # The current generation's, frozen: change the map through #pin,
9
+ # #pin_all_from and #draw.
10
+ def packages = @pins.packages
11
+ def directories = @pins.directories
6
12
 
7
13
  PIN_REGEX = /^pin\s+["']([^"']+)["']/.freeze # :nodoc:
8
14
 
@@ -13,21 +19,25 @@ class Importmap::Map
13
19
  class InvalidFile < StandardError; end
14
20
 
15
21
  def initialize
16
- @integrity = false
17
- @packages, @directories = {}, {}
18
- @cache = {}
22
+ @pins = Importmap::PinSet.new
23
+ # Serialises the writers (draws, pins, sweeps), each of which builds the
24
+ # next Importmap::PinSet on a draft. A render never takes it, so no
25
+ # request waits while config/importmap.rb is re-evaluated.
26
+ @writer = Mutex.new
19
27
  end
20
28
 
21
29
  def draw(path = nil, &block)
22
- if path && File.exist?(path)
23
- begin
24
- instance_eval(File.read(path), path.to_s)
25
- rescue StandardError => e
26
- Rails.logger.error "Unable to parse import map from #{path}: #{e.message}"
27
- raise InvalidFile, "Unable to parse import map from #{path}: #{e.message}"
30
+ drafting do
31
+ if path && File.exist?(path)
32
+ begin
33
+ instance_eval(File.read(path), path.to_s)
34
+ rescue StandardError => e
35
+ Rails.logger.error "Unable to parse import map from #{path}: #{e.message}"
36
+ raise InvalidFile, "Unable to parse import map from #{path}: #{e.message}"
37
+ end
38
+ elsif block_given?
39
+ instance_eval(&block)
28
40
  end
29
- elsif block_given?
30
- instance_eval(&block)
31
41
  end
32
42
 
33
43
  self
@@ -66,20 +76,32 @@ class Importmap::Map
66
76
  # * For Propshaft, you must configure +config.assets.integrity_hash_algorithm+
67
77
  # * External CDN packages should provide their own integrity hashes
68
78
  def enable_integrity!
69
- clear_cache
70
- @integrity = true
79
+ drafting { |draft| draft.integrity = true }
71
80
  end
72
81
 
73
82
  def pin(name, to: nil, preload: true, integrity: true)
74
- clear_cache
75
- @packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity)
83
+ drafting { |draft| draft.packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity) }
76
84
  end
77
85
 
78
86
  def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: true)
79
- clear_cache
80
- @directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity)
87
+ drafting { |draft| draft.directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity) }
88
+ end
89
+
90
+ # Deep-freezes every pin and directory, Ractor-shareable where Ruby has
91
+ # Ractors, and every set of them the map publishes from now on: the engine
92
+ # calls it once the app has booted (config.importmap.finalize). The map
93
+ # still changes as before: a draw, a pin, a development reload or a cache
94
+ # sweep publishes a new, finalized set, so no reader is ever handed a pin
95
+ # another thread can change. A value the app pinned that no Ractor can
96
+ # share is left as it is (see Importmap::Shareable). Returns the map.
97
+ def finalize!
98
+ @writer.synchronize { @pins = @pins.finalized }
99
+ self
81
100
  end
82
101
 
102
+ # Whether #finalize! has been called.
103
+ def finalized? = @pins.finalized?
104
+
83
105
  # Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to
84
106
  # `path_to_asset`, such as `ActionController::Base.helpers` or `ApplicationController.helpers`. You'll want to use the
85
107
  # resolver that has been configured for the `asset_host` you want these resolved paths to use. In case you need to
@@ -136,12 +158,12 @@ class Importmap::Map
136
158
  # # Use a custom cache key for different asset hosts
137
159
  # packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host")
138
160
  def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages)
139
- cache_as(cache_key) do
140
- expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package|
161
+ cache_as(cache_key) do |pins|
162
+ expanded_preloading_packages_and_directories(pins, entry_point:).filter_map do |_, package|
141
163
  resolved_path = resolve_asset_path(package.path, resolver: resolver)
142
164
  next unless resolved_path
143
165
 
144
- resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver)
166
+ resolved_integrity = resolve_integrity_value(pins, package.integrity, package.path, resolver: resolver)
145
167
 
146
168
  package = MappedFile.new(
147
169
  name: package.name,
@@ -161,9 +183,9 @@ class Importmap::Map
161
183
  # want these resolved paths to use. In case you need to resolve for different asset hosts, you can pass in a custom
162
184
  # `cache_key` to vary the cache used by this method for the different cases.
163
185
  def to_json(resolver:, cache_key: :json)
164
- cache_as(cache_key) do
165
- packages = expanded_packages_and_directories
166
- map = build_import_map(packages, resolver: resolver)
186
+ cache_as(cache_key) do |pins|
187
+ packages = expanded_packages_and_directories(pins)
188
+ map = build_import_map(pins, packages, resolver: resolver)
167
189
  JSON.pretty_generate(map)
168
190
  end
169
191
  end
@@ -199,24 +221,44 @@ class Importmap::Map
199
221
  # #to_json is about to resolve, before a resolver has touched it. Returns an
200
222
  # Enumerator without a block.
201
223
  def each_expanded_package(&block)
202
- expanded_packages_and_directories.each(&block)
224
+ expanded_packages_and_directories(@pins).each(&block)
203
225
  end
204
226
 
205
227
  private
206
228
  MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true)
207
229
  MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)
208
230
 
231
+ # Memoised in the generation it renders, which the block is handed: a
232
+ # render reads one Importmap::PinSet start to finish, so a draw landing
233
+ # mid-render can neither mix two versions of the map into it nor have it
234
+ # cached over the newer one.
209
235
  def cache_as(name)
210
- if result = @cache[name.to_s]
211
- result
212
- else
213
- @cache[name.to_s] = yield
214
- end
236
+ pins = @pins
237
+ pins.fetch(name.to_s) { yield pins }
215
238
  end
216
239
 
240
+ # The same pins as a new generation, so every render is redone. Inside a
241
+ # draw there is nothing to do: the draw publishes a new generation itself.
217
242
  def clear_cache
218
- @cache.clear
219
- @graph = nil
243
+ return if @writer.owned?
244
+
245
+ @writer.synchronize { @pins = @pins.recached }
246
+ end
247
+
248
+ # Every change is written into a draft of the current generation and the
249
+ # finished generation published whole. A pin inside a draw lands in that
250
+ # draw's draft; a draw that raises publishes nothing.
251
+ def drafting
252
+ return yield @draft if @writer.owned?
253
+
254
+ @writer.synchronize do
255
+ @draft = @pins.draft
256
+ result = yield @draft
257
+ @pins = @draft.to_pin_set
258
+ result
259
+ ensure
260
+ @draft = nil
261
+ end
220
262
  end
221
263
 
222
264
  def rescuable_asset_error?(error)
@@ -242,29 +284,29 @@ class Importmap::Map
242
284
  end
243
285
  end
244
286
 
245
- def build_import_map(packages, resolver:)
287
+ def build_import_map(pins, packages, resolver:)
246
288
  map = { "imports" => resolve_asset_paths(packages, resolver: resolver) }
247
- integrity = build_integrity_hash(packages, resolver: resolver)
289
+ integrity = build_integrity_hash(pins, packages, resolver: resolver)
248
290
  map["integrity"] = integrity unless integrity.empty?
249
291
  map
250
292
  end
251
293
 
252
- def build_integrity_hash(packages, resolver:)
294
+ def build_integrity_hash(pins, packages, resolver:)
253
295
  packages.filter_map do |name, mapping|
254
296
  next unless mapping.integrity
255
297
 
256
298
  resolved_path = resolve_asset_path(mapping.path, resolver: resolver)
257
299
  next unless resolved_path
258
300
 
259
- integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver)
301
+ integrity_value = resolve_integrity_value(pins, mapping.integrity, mapping.path, resolver: resolver)
260
302
  next unless integrity_value
261
303
 
262
304
  [resolved_path, integrity_value]
263
305
  end.to_h
264
306
  end
265
307
 
266
- def resolve_integrity_value(integrity, path, resolver:)
267
- return unless @integrity
308
+ def resolve_integrity_value(pins, integrity, path, resolver:)
309
+ return unless pins.integrity
268
310
 
269
311
  case integrity
270
312
  when true
@@ -274,9 +316,9 @@ class Importmap::Map
274
316
  end
275
317
  end
276
318
 
277
- def expanded_preloading_packages_and_directories(entry_point:)
278
- preloading = expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
279
- reachable_only(preloading, entry_point: entry_point)
319
+ def expanded_preloading_packages_and_directories(pins, entry_point:)
320
+ preloading = expanded_packages_and_directories(pins).select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
321
+ reachable_only(pins, preloading, entry_point: entry_point)
280
322
  end
281
323
 
282
324
  # With config.importmap.preload_strategy == :reachable, a pin that says
@@ -285,32 +327,33 @@ class Importmap::Map
285
327
  # page without anyone maintaining a `preload: false` for it and for
286
328
  # everything it depends on. A pin naming the entry point is the app
287
329
  # overruling the graph, and `preload: false` is off either way.
288
- def reachable_only(packages, entry_point:)
330
+ def reachable_only(pins, packages, entry_point:)
289
331
  return packages unless Rails.application.config.importmap.preload_strategy == :reachable
290
332
 
291
- reachable = graph.reachable_from(Array(entry_point))
333
+ reachable = graph(pins).reachable_from(Array(entry_point))
292
334
  packages.select { |name, mapping| mapping.preload != true || reachable.include?(name) }
293
335
  end
294
336
 
295
- # Memoised beside the rendered map rather than inside it: #cache_as shares
296
- # one namespace with the cache_key the preload helper passes, which is the
297
- # entry point's own name, so an app with an entry point named "graph" would
298
- # read this back as its preload set. Dropped by the same clear_cache the
299
- # sweeper calls when a .js file under a watched directory changes.
300
- def graph
301
- @graph ||= Importmap::Graph.new(self, roots: asset_paths)
337
+ # Memoised in the generation beside its renders, under a Symbol: every
338
+ # #cache_as key is a String, and the preload helper passes the entry
339
+ # point's own name as one, so an app with an entry point named "graph"
340
+ # can't read this back as its preload set. Redone with the rest of the
341
+ # generation's cache when a draw, or the sweeper seeing a .js file under a
342
+ # watched directory change, publishes a new one.
343
+ def graph(pins)
344
+ pins.fetch(:graph) { Importmap::Graph.new(self, roots: asset_paths, entries: expanded_packages_and_directories(pins)) }
302
345
  end
303
346
 
304
347
  def asset_paths
305
348
  (config = Rails.application.config).respond_to?(:assets) ? config.assets.paths : []
306
349
  end
307
350
 
308
- def expanded_packages_and_directories
309
- @packages.dup.tap { |expanded| expand_directories_into expanded }
351
+ def expanded_packages_and_directories(pins)
352
+ pins.packages.dup.tap { |expanded| expand_directories_into expanded, pins.directories }
310
353
  end
311
354
 
312
- def expand_directories_into(paths)
313
- @directories.values.each do |mapping|
355
+ def expand_directories_into(paths, directories)
356
+ directories.values.each do |mapping|
314
357
  if (absolute_path = absolute_root_of(mapping.dir)).exist?
315
358
  find_javascript_files_in_tree(absolute_path).each do |filename|
316
359
  module_filename = filename.relative_path_from(absolute_path)
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "open3"
2
4
  require "tmpdir"
5
+ require "importmap/shareable"
3
6
 
4
7
  # Minifies a vendored package with whichever JavaScript minifier is installed:
5
8
  # bun, esbuild or terser, looked up in node_modules/.bin and then on PATH.
@@ -8,11 +11,12 @@ require "tmpdir"
8
11
  class Importmap::Minifier
9
12
  Error = Class.new(StandardError)
10
13
 
11
- TOOLS = {
14
+ # Shareable: the lambdas capture nothing, and their self is this class.
15
+ TOOLS = Importmap::Shareable.make({
12
16
  "bun" => ->(input, output) { [ "build", "--no-bundle", "--minify", "--format=esm", "--target=browser", input, "--outfile=#{output}" ] },
13
17
  "esbuild" => ->(input, output) { [ input, "--minify", "--format=esm", "--outfile=#{output}" ] },
14
18
  "terser" => ->(input, output) { [ input, "--module", "--compress", "--mangle", "--output", output ] }
15
- }.freeze # :nodoc:
19
+ }) # :nodoc:
16
20
 
17
21
  class << self
18
22
  def detect
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "strscan"
2
4
 
3
5
  # Decides whether a downloaded ESM file can stand alone as the single file an
data/lib/importmap/npm.rb CHANGED
@@ -1,7 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "net/http"
2
4
  require "uri"
3
5
  require "json"
4
6
  require "importmap/http_retries"
7
+ require "importmap/shareable"
5
8
 
6
9
  class Importmap::Npm
7
10
  include Importmap::HttpRetries
@@ -11,7 +14,14 @@ class Importmap::Npm
11
14
  Error = Class.new(StandardError)
12
15
  HTTPError = Class.new(Error)
13
16
 
14
- singleton_class.attr_accessor :base_uri
17
+ # The registry, or a mirror of it set in an initializer.
18
+ singleton_class.attr_reader :base_uri
19
+
20
+ def self.base_uri=(value)
21
+ # A boot-time setting, written before the commands that read it, and
22
+ # stored deeply frozen (Ractor-shareable), so none can change it in place.
23
+ @base_uri = Importmap::Shareable.make(value) # rubocop:disable ThreadSafety/ClassInstanceVariable
24
+ end
15
25
  self.base_uri = URI("https://registry.npmjs.org")
16
26
 
17
27
  def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript")
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "uri"
2
4
  require "importmap/module_inspector"
3
5
  require "importmap/integrity"
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "net/http"
2
4
  require "uri"
3
5
  require "json"
@@ -8,6 +10,7 @@ require "importmap/vendored_graph"
8
10
  require "importmap/http_retries"
9
11
  require "importmap/integrity"
10
12
  require "importmap/esm_run"
13
+ require "importmap/shareable"
11
14
 
12
15
  class Importmap::Packager
13
16
  include Importmap::HttpRetries
@@ -132,7 +135,14 @@ class Importmap::Packager
132
135
  end
133
136
  end
134
137
 
135
- singleton_class.attr_accessor :endpoint
138
+ # jspm's generator, or another one set in an initializer.
139
+ singleton_class.attr_reader :endpoint
140
+
141
+ def self.endpoint=(value)
142
+ # A boot-time setting, written before the commands that read it, and
143
+ # stored deeply frozen (Ractor-shareable), so none can change it in place.
144
+ @endpoint = Importmap::Shareable.make(value) # rubocop:disable ThreadSafety/ClassInstanceVariable
145
+ end
136
146
  self.endpoint = URI("https://api.jspm.io/generate")
137
147
 
138
148
  # CDNs reset connections and rate-limit bursts. Each request is tried this
@@ -158,12 +168,17 @@ class Importmap::Packager
158
168
  end
159
169
 
160
170
  # Anything responding to #call(source) => String. Defaults to the first of
161
- # bun, esbuild or terser found on the machine.
162
- singleton_class.attr_writer :minifier
171
+ # bun, esbuild or terser found on the machine, looked for when this file
172
+ # loads, and stored shareable; assigning nil puts that default back. A
173
+ # minifier an app sets is used as given: a callable is seldom shareable, and
174
+ # only the CLI's own thread calls it.
175
+ singleton_class.attr_reader :minifier
163
176
 
164
- def self.minifier
165
- @minifier ||= Importmap::Minifier.new
177
+ def self.minifier=(value)
178
+ # A boot-time setting, written before the commands that read it.
179
+ @minifier = value || Importmap::Shareable.make(Importmap::Minifier.new) # rubocop:disable ThreadSafety/ClassInstanceVariable
166
180
  end
181
+ self.minifier = nil
167
182
 
168
183
  attr_reader :vendor_path
169
184
 
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "importmap/shareable"
4
+
5
+ # One generation of an Importmap::Map: its pins, its pin_all_from directories
6
+ # and whether integrity is on, frozen together, plus the cache of what was
7
+ # rendered from them.
8
+ #
9
+ # The map never changes a generation in place. A draw, a pin or a cache sweep
10
+ # writes into a Draft of the current one and the map swaps the finished
11
+ # generation in whole, so a request rendering mid-reload renders the
12
+ # generation before it rather than a half-drawn map. What a render caches
13
+ # lands in the generation it read: a swap leaves the old cache behind, where
14
+ # clearing one shared cache would let a render that started before the change
15
+ # write the old map back into it.
16
+ class Importmap::PinSet
17
+ # A generation being drawn: the same things, writable, for the one writer
18
+ # holding the map's lock. The generation it publishes is finalized when the
19
+ # one it was drafted from was.
20
+ Draft = Struct.new(:packages, :directories, :integrity, :finalized) do
21
+ def to_pin_set = Importmap::PinSet.new(packages: packages, directories: directories, integrity: integrity, finalized: finalized)
22
+ end
23
+
24
+ attr_reader :packages, :directories, :integrity
25
+
26
+ def initialize(packages: {}, directories: {}, integrity: false, finalized: false)
27
+ @packages = finalized ? deep_freeze(packages) : packages.freeze
28
+ @directories = finalized ? deep_freeze(directories) : directories.freeze
29
+ @integrity = integrity
30
+ @finalized = finalized
31
+ @lock = Mutex.new
32
+ @cache = {}
33
+ freeze
34
+ end
35
+
36
+ # Whether every pin and directory is deeply frozen: Ractor-shareable where
37
+ # Ruby has Ractors, but for a value an app gave that no Ractor can share.
38
+ def finalized? = @finalized
39
+
40
+ def draft
41
+ Draft.new(packages.dup, directories.dup, integrity, finalized?)
42
+ end
43
+
44
+ # The same pins with an empty cache: what a sweep publishes when a watched
45
+ # .js file changes and every render has to be redone.
46
+ def recached
47
+ self.class.new(packages: packages, directories: directories, integrity: integrity, finalized: finalized?)
48
+ end
49
+
50
+ # The same pins deeply frozen, as a new generation with an empty cache.
51
+ # Every generation drafted from it is finalized in turn.
52
+ def finalized
53
+ finalized? ? self : self.class.new(packages: packages, directories: directories, integrity: integrity, finalized: true)
54
+ end
55
+
56
+ # The value cached under +key+, else the block's. The block runs outside the
57
+ # lock, because it calls the app's asset resolver, which may take locks of
58
+ # its own; when two renders miss at once the first one stored is kept and
59
+ # both return it.
60
+ def fetch(key)
61
+ cached = @lock.synchronize { @cache[key] }
62
+ return cached if cached
63
+
64
+ rendered = yield
65
+ @lock.synchronize { @cache[key] ||= rendered }
66
+ end
67
+
68
+ private
69
+ # Each pin on its own, so one holding a value no Ractor can share (see
70
+ # Importmap::Shareable) leaves only itself unshared. The pins are the
71
+ # map's own structs: one that can't be shared is still frozen, around the
72
+ # value the app gave it.
73
+ def deep_freeze(entries)
74
+ entries.each_value { |entry| Importmap::Shareable.make(entry).freeze }
75
+ Importmap::Shareable.make(entries.freeze)
76
+ end
77
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "importmap/packager"
2
4
 
3
5
  # The CDNs a package that names none of its own is asked of, in order.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "active_support"
2
4
  require "active_support/core_ext/module/delegation"
3
5
 
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Deep-freezes a value set once at boot and read by every thread after it, so
4
+ # no reader can be handed something another thread is still changing:
5
+ # Ractor.make_shareable where Ruby has Ractors (CRuby 3.0+), which also lets
6
+ # a non-main Ractor read it. Where it hasn't (JRuby, TruffleRuby) the value
7
+ # and the hashes, arrays, structs and strings inside it are frozen. Freezes in
8
+ # place and returns the value.
9
+ #
10
+ # Never raises. A value no Ractor can share (a lambda that closes over the
11
+ # app's state, a logger, anything holding a Mutex or an IO) is returned as it
12
+ # is, and keeps working on the main Ractor only. A Hash, Array or Struct
13
+ # around a Proc that can't be shared is frozen around it; the Proc is not.
14
+ module Importmap::Shareable
15
+ def self.make(value)
16
+ return freeze_all(value) unless ractors?
17
+ return value if ::Ractor.shareable?(value)
18
+ return value unless freezable?(value)
19
+
20
+ ::Ractor.make_shareable(value)
21
+ rescue ::Ractor::Error
22
+ value
23
+ end
24
+
25
+ def self.ractors? = defined?(::Ractor) ? true : false
26
+
27
+ # Whether Ractor.make_shareable can run on +value+ in place without giving
28
+ # up half-way, which would leave it half-frozen (a logger, frozen around the
29
+ # Monitor it can't share). Everything but a Proc is tried on a copy first,
30
+ # which raises without touching the original. A Proc can't be copied, but
31
+ # make_shareable checks a Proc before it freezes it, so it needs no dry run;
32
+ # one inside any other object is never tried. +seen+ holds the hashes,
33
+ # arrays and structs already being checked, so one that holds itself ends.
34
+ def self.freezable?(value, seen = {}.compare_by_identity)
35
+ case value
36
+ when Hash, Array, Struct
37
+ return true if seen.key?(value)
38
+
39
+ seen[value] = true
40
+ value.is_a?(Hash) ? value.all? { |key, item| freezable?(key, seen) && freezable?(item, seen) } : value.all? { |item| freezable?(item, seen) }
41
+ when Proc then true
42
+ else
43
+ ::Ractor.make_shareable(value, copy: true) unless ::Ractor.shareable?(value)
44
+ true
45
+ end
46
+ rescue ::Ractor::Error, TypeError
47
+ false
48
+ end
49
+
50
+ # Without Ractors: the value itself, and the hashes, arrays, structs and
51
+ # strings inside it. Any other object inside it is left as it is.
52
+ # +seen+ as in freezable?.
53
+ def self.freeze_all(value, seen = {}.compare_by_identity)
54
+ return value if seen.key?(value)
55
+
56
+ seen[value] = true
57
+ case value
58
+ when Hash then value.each { |key, item| freeze_nested(key, seen); freeze_nested(item, seen) }
59
+ when Array, Struct then value.each { |item| freeze_nested(item, seen) }
60
+ end
61
+ value.freeze
62
+ end
63
+
64
+ def self.freeze_nested(value, seen)
65
+ freeze_all(value, seen) if value.is_a?(Hash) || value.is_a?(Array) || value.is_a?(Struct) || value.is_a?(String)
66
+ end
67
+ private_class_method :ractors?, :freezable?, :freeze_all, :freeze_nested
68
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "fileutils"
2
4
  require "pathname"
3
5
 
@@ -1,6 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Importmap
2
4
  # importmap-plus versions independently of the importmap-rails it forks.
3
5
  # UPSTREAM_VERSION is the importmap-rails release this tracks.
4
- VERSION = "2.0.2"
6
+ VERSION = "2.1.0"
5
7
  UPSTREAM_VERSION = "2.2.3"
6
8
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  # The entry point Bundler requires for `gem "importmap-plus"`. The constants
2
4
  # stay under Importmap:: so this gem drops into any app that used
3
5
  # importmap-rails without touching a single call site.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Importmap
2
4
  end
3
5
 
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  APPLICATION_LAYOUT_PATH = Rails.root.join("app/views/layouts/application.html.erb")
2
4
 
3
5
  if APPLICATION_LAYOUT_PATH.exist?
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  namespace :importmap do
2
4
  desc "Setup Importmap for the app"
3
5
  task :install do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: importmap-plus
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.2
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Heinemeier Hansson
@@ -17,6 +17,9 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: 6.0.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9.0'
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
@@ -24,6 +27,9 @@ dependencies:
24
27
  - - ">="
25
28
  - !ruby/object:Gem::Version
26
29
  version: 6.0.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9.0'
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: activesupport
29
35
  requirement: !ruby/object:Gem::Requirement
@@ -31,6 +37,9 @@ dependencies:
31
37
  - - ">="
32
38
  - !ruby/object:Gem::Version
33
39
  version: 6.0.0
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '9.0'
34
43
  type: :runtime
35
44
  prerelease: false
36
45
  version_requirements: !ruby/object:Gem::Requirement
@@ -38,6 +47,9 @@ dependencies:
38
47
  - - ">="
39
48
  - !ruby/object:Gem::Version
40
49
  version: 6.0.0
50
+ - - "<"
51
+ - !ruby/object:Gem::Version
52
+ version: '9.0'
41
53
  - !ruby/object:Gem::Dependency
42
54
  name: actionpack
43
55
  requirement: !ruby/object:Gem::Requirement
@@ -45,6 +57,9 @@ dependencies:
45
57
  - - ">="
46
58
  - !ruby/object:Gem::Version
47
59
  version: 6.0.0
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '9.0'
48
63
  type: :runtime
49
64
  prerelease: false
50
65
  version_requirements: !ruby/object:Gem::Requirement
@@ -52,6 +67,9 @@ dependencies:
52
67
  - - ">="
53
68
  - !ruby/object:Gem::Version
54
69
  version: 6.0.0
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '9.0'
55
73
  description: 'A drop-in replacement for importmap-rails. Same Importmap:: API, same
56
74
  pins, same import map, plus `bin/importmap pin --minify`, `--from esm.run` for jsDelivr''s
57
75
  bundled builds, `--lock` to hold a package at a version, and a pin comment that
@@ -86,8 +104,10 @@ files:
86
104
  - lib/importmap/npm.rb
87
105
  - lib/importmap/package_graph.rb
88
106
  - lib/importmap/packager.rb
107
+ - lib/importmap/pin_set.rb
89
108
  - lib/importmap/provider_chain.rb
90
109
  - lib/importmap/reloader.rb
110
+ - lib/importmap/shareable.rb
91
111
  - lib/importmap/vendored_graph.rb
92
112
  - lib/importmap/version.rb
93
113
  - lib/install/bin/importmap
@@ -99,7 +119,7 @@ licenses:
99
119
  - MIT
100
120
  metadata:
101
121
  homepage_uri: https://github.com/zoolutions/importmap-plus
102
- source_code_uri: https://github.com/zoolutions/importmap-plus
122
+ source_code_uri: https://github.com/zoolutions/importmap-plus/tree/main
103
123
  changelog_uri: https://github.com/zoolutions/importmap-plus/blob/main/CHANGELOG.md
104
124
  rdoc_options: []
105
125
  require_paths: