importmap-plus 1.0.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.
@@ -0,0 +1,297 @@
1
+ require "thor"
2
+ require "importmap/packager"
3
+ require "importmap/npm"
4
+
5
+ class Importmap::Commands < Thor
6
+ include Thor::Actions
7
+
8
+ def self.exit_on_failure?
9
+ false
10
+ end
11
+
12
+ desc "pin [*PACKAGES]", "Pin new packages"
13
+ option :env, type: :string, aliases: :e, default: "production"
14
+ option :from, type: :string, aliases: :f, desc: "CDN to resolve from: jspm (default), unpkg, jsdelivr, esm.sh, skypack or esm.run"
15
+ option :preload, type: :string, repeatable: true, desc: "Can be used multiple times"
16
+ option :remote, type: :boolean, default: false, desc: "Pin to the remote URL instead of vendoring a download"
17
+ option :minify, type: :boolean, desc: "Minify the vendored download with bun, esbuild or terser"
18
+ def pin(*packages)
19
+ for_each_import_grouped_by_provider(packages, env: options[:env], from: options[:from]) do |package, url|
20
+ pin_package(package, url, preload: options[:preload], remote: options[:remote], env: options[:env],
21
+ minify: options[:minify], from: options[:from])
22
+ end
23
+ end
24
+
25
+ desc "unpin [*PACKAGES]", "Unpin existing packages"
26
+ option :env, type: :string, aliases: :e, default: "production"
27
+ option :from, type: :string, aliases: :f, default: "jspm"
28
+ def unpin(*packages)
29
+ for_each_import(packages, env: options[:env], from: options[:from]) do |package, url|
30
+ if packager.packaged?(package)
31
+ puts %(Unpinning and removing "#{package}")
32
+ packager.remove(package)
33
+ end
34
+ end
35
+ end
36
+
37
+ desc "pristine", "Redownload all pinned packages"
38
+ option :env, type: :string, aliases: :e, default: "production"
39
+ option :from, type: :string, aliases: :f, desc: "CDN to resolve from; defaults to the one each package was vendored from"
40
+ option :minify, type: :boolean, desc: "Minify every download; defaults to what each vendored file already is"
41
+ def pristine
42
+ packages = prepare_packages_with_versions
43
+
44
+ for_each_import_grouped_by_provider(packages, env: options[:env], from: options[:from]) do |package, url|
45
+ if packager.remote_pin?(package)
46
+ puts %(Skipping "#{package}" (pinned to remote URL))
47
+ else
48
+ minify = options[:minify].nil? ? vendored_minified?(package) : options[:minify]
49
+
50
+ puts %(Downloading "#{package}" to #{packager.vendor_path}/#{package}.js from #{url}#{" (minified)" if minify})
51
+
52
+ pin_esm_run_dependencies packager.download(package, url, minify: minify), minify: minify
53
+ record_provenance(package, url, minify) if provenance_changed?(package, url, minify)
54
+ end
55
+ end
56
+ end
57
+
58
+ desc "json", "Show the full importmap in json"
59
+ def json
60
+ require Rails.root.join("config/environment")
61
+ puts Rails.application.importmap.to_json(resolver: ActionController::Base.helpers)
62
+ end
63
+
64
+ desc "audit", "Run a security audit"
65
+ def audit
66
+ vulnerable_packages = npm.vulnerable_packages
67
+
68
+ if vulnerable_packages.any?
69
+ table = [["Package", "Severity", "Vulnerable versions", "Vulnerability"]]
70
+ vulnerable_packages.each { |p| table << [p.name, p.severity, p.vulnerable_versions, p.vulnerability] }
71
+
72
+ puts_table(table)
73
+ vulnerabilities = 'vulnerability'.pluralize(vulnerable_packages.size)
74
+ severities = vulnerable_packages.map(&:severity).tally.sort_by(&:last).reverse
75
+ .map { |severity, count| "#{count} #{severity}" }
76
+ .join(", ")
77
+ puts " #{vulnerable_packages.size} #{vulnerabilities} found: #{severities}"
78
+
79
+ exit 1
80
+ else
81
+ puts "No vulnerable packages found"
82
+ end
83
+ end
84
+
85
+ desc "outdated", "Check for outdated packages"
86
+ def outdated
87
+ if (outdated_packages = npm.outdated_packages).any?
88
+ table = [["Package", "Current", "Latest"]]
89
+ outdated_packages.each { |p| table << [p.name, p.current_version, p.latest_version || p.error] }
90
+
91
+ puts_table(table)
92
+ packages = 'package'.pluralize(outdated_packages.size)
93
+ puts " #{outdated_packages.size} outdated #{packages} found"
94
+
95
+ exit 1
96
+ else
97
+ puts "No outdated packages found"
98
+ end
99
+ end
100
+
101
+ desc "update", "Update outdated package pins"
102
+ def update
103
+ if (outdated_packages = npm.outdated_packages).any?
104
+ for_each_import_grouped_by_provider(outdated_packages.map(&:name), env: "production") do |package, url|
105
+ pin_package(package, url)
106
+ end
107
+ else
108
+ puts "No outdated packages found"
109
+ end
110
+ end
111
+
112
+ desc "packages", "Print out packages with version numbers"
113
+ def packages
114
+ puts npm.packages_with_versions.map { |x| x.join(' ') }
115
+ end
116
+
117
+ private
118
+ def packager
119
+ @packager ||= Importmap::Packager.new
120
+ end
121
+
122
+ def npm
123
+ @npm ||= Importmap::Npm.new
124
+ end
125
+
126
+ def pin_package(package, url, preload: nil, remote: false, env: "production", minify: nil, from: nil)
127
+ existing_options = packager.extract_existing_pin_options(package)[package] || {}
128
+ preload = existing_options[:preload] if preload.nil?
129
+ existing_url = existing_options[:to] if existing_options[:to].to_s.match?(Importmap::Packager::REMOTE_URL_REGEXP)
130
+
131
+ if existing_url
132
+ repin_remote_package(package, url, existing_url, preload, env: env, from: from)
133
+ elsif remote
134
+ pin_remote_package(package, url, preload)
135
+ else
136
+ pin_vendored_package(package, url, preload, minify: minify)
137
+ end
138
+ end
139
+
140
+ def pin_vendored_package(package, url, preload, minify: nil)
141
+ minify = vendored_minified?(package) if minify.nil?
142
+
143
+ puts %(Pinning "#{package}" to #{packager.vendor_path}/#{package}.js via download from #{url}#{" (minified)" if minify})
144
+
145
+ dependencies = packager.download(package, url, minify: minify)
146
+
147
+ update_importmap_with_pin(package, packager.vendored_pin_for(package, url, preload, minify: minify))
148
+
149
+ pin_esm_run_dependencies(dependencies, preload: preload, minify: minify)
150
+ end
151
+
152
+ # An esm.run bundle imports its dependencies as bare specifiers after
153
+ # download, so each one needs a pin. Pins the app already has win: the
154
+ # bundle then resolves to whatever version the app chose.
155
+ def pin_esm_run_dependencies(dependencies, preload: nil, minify: nil)
156
+ dependencies.each do |dependency, url|
157
+ if packager.packaged?(dependency)
158
+ puts %(Keeping existing pin for "#{dependency}" (bundle was built against #{packager.extract_package_version_from(url)}))
159
+ else
160
+ pin_package(dependency, url, preload: preload, minify: minify)
161
+ end
162
+ end
163
+ end
164
+
165
+ # pristine can change where a package comes from (--from) or whether it is
166
+ # minified (--minify) without re-resolving its pin, so rewrite just the
167
+ # comment those are recorded in. Left alone otherwise: a pin may carry
168
+ # options, such as integrity, that a rewrite would drop.
169
+ def record_provenance(package, url, minify)
170
+ preload = (packager.extract_existing_pin_options(package)[package] || {})[:preload]
171
+
172
+ update_importmap_with_pin(package, packager.vendored_pin_for(package, url, preload, minify: minify))
173
+ end
174
+
175
+ def provenance_changed?(package, url, minify)
176
+ current = packager.pin_provenance(package) || {}
177
+ desired = packager.provenance_for(url, minify: minify)
178
+
179
+ current.values_at(:provider, :minified) != desired.values_at(:provider, :minified)
180
+ end
181
+
182
+ def vendored_minified?(package)
183
+ packager.pin_provenance(package)&.dig(:minified) || false
184
+ end
185
+
186
+ # A vendored package keeps coming from the CDN its pin comment names, the
187
+ # way a remote pin keeps its provider, unless --from says otherwise.
188
+ # Packages that aren't pinned yet resolve from jspm.
189
+ def for_each_import_grouped_by_provider(packages, env:, from: nil, &block)
190
+ packages.group_by { |spec| from || vendored_provider_for(spec) || "jspm" }.each do |provider, group|
191
+ for_each_import(group, env: env, from: provider, &block)
192
+ end
193
+ end
194
+
195
+ def vendored_provider_for(spec)
196
+ packager.pin_provenance(packager.package_key_for(spec))&.dig(:provider)
197
+ end
198
+
199
+ def pin_remote_package(package, url, preload)
200
+ puts %(Pinning "#{package}" to #{url})
201
+
202
+ packager.remove_existing_package_file(package)
203
+
204
+ update_importmap_with_pin(package, packager.pin_for(package, url, preloads: preload))
205
+ end
206
+
207
+ def repin_remote_package(package, url, existing_url, preload, env:, from: nil)
208
+ # `url` was already resolved from the requested CDN, so an explicit
209
+ # --from moves the pin instead of being overruled by its current one.
210
+ return pin_remote_package(package, url, preload) if from
211
+
212
+ provider = packager.provider_for_url(existing_url)
213
+
214
+ if provider.nil?
215
+ puts %(Skipping "#{package}" pinned to custom URL #{existing_url})
216
+ elsif provider == packager.provider_for_url(url)
217
+ pin_remote_package(package, url, preload)
218
+ elsif (provider_url = resolve_url_from_provider(package, url, provider, env: env))
219
+ pin_remote_package(package, provider_url, preload)
220
+ else
221
+ puts %(Keeping "#{package}" pinned to #{existing_url} (couldn't resolve it from #{provider}))
222
+ end
223
+ end
224
+
225
+ def resolve_url_from_provider(package, reference_url, provider, env:)
226
+ version = packager.extract_package_version_from(reference_url)
227
+ response = packager.import("#{package}#{version}", env: env, from: provider)
228
+
229
+ response && response[:imports][package]
230
+ rescue Importmap::Packager::Error => error
231
+ puts %(Failed to resolve "#{package}" from #{provider}: #{error.message})
232
+ nil
233
+ end
234
+
235
+ def update_importmap_with_pin(package, pin)
236
+ new_pin = "#{pin}\n"
237
+
238
+ if packager.packaged?(package)
239
+ gsub_file("config/importmap.rb", Importmap::Map.pin_line_regexp_for(package), pin, verbose: false)
240
+ else
241
+ append_to_file("config/importmap.rb", new_pin, verbose: false)
242
+ end
243
+
244
+ packager.reload!
245
+ end
246
+
247
+ def handle_package_not_found(packages, from)
248
+ puts "Couldn't find any packages in #{packages.inspect} on #{from}"
249
+ end
250
+
251
+ def remove_line_from_file(path, pattern)
252
+ path = File.expand_path(path, destination_root)
253
+
254
+ all_lines = File.readlines(path)
255
+ with_lines_removed = all_lines.select { |line| line !~ pattern }
256
+
257
+ File.open(path, "w") do |file|
258
+ with_lines_removed.each { |line| file.write(line) }
259
+ end
260
+ end
261
+
262
+ def puts_table(array)
263
+ column_sizes = array.reduce([]) do |lengths, row|
264
+ row.each_with_index.map{ |iterand, index| [lengths[index] || 0, iterand.to_s.length].max }
265
+ end
266
+
267
+ divider = "|" + (column_sizes.map { |s| "-" * (s + 2) }.join('|')) + '|'
268
+ array.each_with_index do |row, row_number|
269
+ row = row.fill(nil, row.size..(column_sizes.size - 1))
270
+ row = row.each_with_index.map { |v, i| v.to_s + " " * (column_sizes[i] - v.to_s.length) }
271
+ puts "| " + row.join(" | ") + " |"
272
+ puts divider if row_number == 0
273
+ end
274
+ end
275
+
276
+ def prepare_packages_with_versions(packages = [])
277
+ if packages.empty?
278
+ npm.packages_with_versions.map do |p, v|
279
+ v.blank? ? p : [p, v].join("@")
280
+ end
281
+ else
282
+ packages
283
+ end
284
+ end
285
+
286
+ def for_each_import(packages, **options, &block)
287
+ response = packager.import(*packages, **options)
288
+
289
+ if response
290
+ response[:imports].each(&block)
291
+ else
292
+ handle_package_not_found(packages, options[:from])
293
+ end
294
+ end
295
+ end
296
+
297
+ Importmap::Commands.start(ARGV)
@@ -0,0 +1,73 @@
1
+ require "importmap/map"
2
+
3
+ # Use Rails.application.importmap to access the map
4
+ Rails::Application.send(:attr_accessor, :importmap)
5
+
6
+ module Importmap
7
+ class Engine < ::Rails::Engine
8
+ config.importmap = ActiveSupport::OrderedOptions.new
9
+ config.importmap.paths = []
10
+ config.importmap.sweep_cache = Rails.env.development? || Rails.env.test?
11
+ config.importmap.cache_sweepers = []
12
+ config.importmap.rescuable_asset_errors = []
13
+
14
+ config.autoload_once_paths = %W( #{root}/app/helpers #{root}/app/controllers )
15
+
16
+ initializer "importmap" do |app|
17
+ app.importmap = Importmap::Map.new
18
+ app.config.importmap.paths << app.root.join("config/importmap.rb")
19
+ app.config.importmap.paths.each { |path| app.importmap.draw(path) }
20
+ end
21
+
22
+ initializer "importmap.reloader" do |app|
23
+ unless app.config.cache_classes
24
+ Importmap::Reloader.new.tap do |reloader|
25
+ reloader.execute
26
+ app.reloaders << reloader
27
+ app.reloader.to_run { reloader.execute }
28
+ end
29
+ end
30
+ end
31
+
32
+ initializer "importmap.cache_sweeper" do |app|
33
+ if app.config.importmap.sweep_cache && !app.config.cache_classes
34
+ app.config.importmap.cache_sweepers << app.root.join("app/javascript")
35
+ app.config.importmap.cache_sweepers << app.root.join("vendor/javascript")
36
+ app.importmap.cache_sweeper(watches: app.config.importmap.cache_sweepers)
37
+
38
+ ActiveSupport.on_load(:action_controller_base) do
39
+ before_action { Rails.application.importmap.cache_sweeper.execute_if_updated }
40
+ end
41
+ end
42
+ end
43
+
44
+ initializer "importmap.assets" do |app|
45
+ if app.config.respond_to?(:assets)
46
+ app.config.assets.paths << Rails.root.join("app/javascript")
47
+ app.config.assets.paths << Rails.root.join("vendor/javascript")
48
+ end
49
+ end
50
+
51
+ initializer "importmap.concerns" do
52
+ ActiveSupport.on_load(:action_controller_base) do
53
+ extend Importmap::Freshness
54
+ end
55
+ end
56
+
57
+ initializer "importmap.helpers" do
58
+ ActiveSupport.on_load(:action_controller_base) do
59
+ helper Importmap::ImportmapTagsHelper
60
+ end
61
+ end
62
+
63
+ initializer "importmap.rescuable_asset_errors" do |app|
64
+ if defined?(Propshaft)
65
+ app.config.importmap.rescuable_asset_errors << Propshaft::MissingAssetError
66
+ end
67
+
68
+ if defined?(Sprockets::Rails)
69
+ app.config.importmap.rescuable_asset_errors << Sprockets::Rails::Helper::AssetNotFound
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,35 @@
1
+ require "net/http"
2
+ require "openssl"
3
+
4
+ # Retries an outbound request on the failures a CDN or a registry hands out
5
+ # under load — a reset connection, a timeout, a 429 or a 5xx — a bounded
6
+ # number of times with a growing pause. Included by Packager and Npm, each of
7
+ # which raises its own HTTPError once the attempts are spent.
8
+ module Importmap::HttpRetries
9
+ RETRYABLE_ERRORS = [ SocketError, SystemCallError, Timeout::Error, EOFError, OpenSSL::SSL::SSLError, Net::ProtocolError ].freeze # :nodoc:
10
+ RETRYABLE_CODES = %w[ 429 500 502 503 504 ].freeze # :nodoc:
11
+
12
+ singleton_class.attr_accessor :attempts, :wait
13
+ self.attempts = 3
14
+ self.wait = 0.5
15
+
16
+ private
17
+ def with_retries(description)
18
+ attempt = 0
19
+
20
+ loop do
21
+ attempt += 1
22
+
23
+ begin
24
+ response = yield
25
+ return response unless attempt < Importmap::HttpRetries.attempts && RETRYABLE_CODES.include?(response.code.to_s)
26
+ rescue *RETRYABLE_ERRORS => error
27
+ unless attempt < Importmap::HttpRetries.attempts
28
+ raise self.class::HTTPError, "Unexpected transport error #{description} (#{error.class}: #{error.message})"
29
+ end
30
+ end
31
+
32
+ sleep Importmap::HttpRetries.wait * attempt
33
+ end
34
+ end
35
+ end