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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +46 -0
- data/MIT-LICENSE +20 -0
- data/README.md +457 -0
- data/Rakefile +17 -0
- data/app/controllers/importmap/freshness.rb +5 -0
- data/app/helpers/importmap/importmap_tags_helper.rb +47 -0
- data/lib/importmap/commands.rb +297 -0
- data/lib/importmap/engine.rb +73 -0
- data/lib/importmap/http_retries.rb +35 -0
- data/lib/importmap/map.rb +319 -0
- data/lib/importmap/minifier.rb +76 -0
- data/lib/importmap/npm.rb +189 -0
- data/lib/importmap/packager.rb +428 -0
- data/lib/importmap/reloader.rb +23 -0
- data/lib/importmap/version.rb +6 -0
- data/lib/importmap-plus.rb +4 -0
- data/lib/importmap-rails.rb +6 -0
- data/lib/install/bin/importmap +4 -0
- data/lib/install/config/importmap.rb +3 -0
- data/lib/install/install.rb +32 -0
- data/lib/tasks/importmap_tasks.rake +9 -0
- metadata +110 -0
|
@@ -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
|