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,319 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
|
|
3
|
+
class Importmap::Map
|
|
4
|
+
attr_reader :packages, :directories
|
|
5
|
+
|
|
6
|
+
PIN_REGEX = /^pin\s+["']([^"']+)["']/.freeze # :nodoc:
|
|
7
|
+
|
|
8
|
+
def self.pin_line_regexp_for(package) # :nodoc:
|
|
9
|
+
/^.*pin\s+["']#{Regexp.escape(package)}["'].*$/.freeze
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
class InvalidFile < StandardError; end
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@integrity = false
|
|
16
|
+
@packages, @directories = {}, {}
|
|
17
|
+
@cache = {}
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def draw(path = nil, &block)
|
|
21
|
+
if path && File.exist?(path)
|
|
22
|
+
begin
|
|
23
|
+
instance_eval(File.read(path), path.to_s)
|
|
24
|
+
rescue StandardError => e
|
|
25
|
+
Rails.logger.error "Unable to parse import map from #{path}: #{e.message}"
|
|
26
|
+
raise InvalidFile, "Unable to parse import map from #{path}: #{e.message}"
|
|
27
|
+
end
|
|
28
|
+
elsif block_given?
|
|
29
|
+
instance_eval(&block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Enables automatic integrity hash calculation for all pinned modules.
|
|
36
|
+
#
|
|
37
|
+
# When enabled, integrity values are included in the importmap JSON for all
|
|
38
|
+
# pinned modules. For local assets served by the Rails asset pipeline,
|
|
39
|
+
# integrity hashes are automatically calculated when +integrity: true+ is
|
|
40
|
+
# specified. For modules with explicit integrity values, those values are
|
|
41
|
+
# included as provided. This provides Subresource Integrity (SRI) protection
|
|
42
|
+
# to ensure JavaScript modules haven't been tampered with.
|
|
43
|
+
#
|
|
44
|
+
# Clears the importmap cache when called to ensure fresh integrity hashes
|
|
45
|
+
# are generated.
|
|
46
|
+
#
|
|
47
|
+
# ==== Examples
|
|
48
|
+
#
|
|
49
|
+
# # config/importmap.rb
|
|
50
|
+
# enable_integrity!
|
|
51
|
+
#
|
|
52
|
+
# # These will now auto-calculate integrity hashes
|
|
53
|
+
# pin "application" # integrity: true by default
|
|
54
|
+
# pin "admin", to: "admin.js" # integrity: true by default
|
|
55
|
+
# pin_all_from "app/javascript/lib" # integrity: true by default
|
|
56
|
+
#
|
|
57
|
+
# # Manual control still works
|
|
58
|
+
# pin "no_integrity", integrity: false
|
|
59
|
+
# pin "custom_hash", integrity: "sha384-abc123..."
|
|
60
|
+
#
|
|
61
|
+
# ==== Notes
|
|
62
|
+
#
|
|
63
|
+
# * Integrity calculation is disabled by default and must be explicitly enabled
|
|
64
|
+
# * Requires asset pipeline support for integrity calculation (Sprockets or Propshaft 1.2+)
|
|
65
|
+
# * For Propshaft, you must configure +config.assets.integrity_hash_algorithm+
|
|
66
|
+
# * External CDN packages should provide their own integrity hashes
|
|
67
|
+
def enable_integrity!
|
|
68
|
+
clear_cache
|
|
69
|
+
@integrity = true
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def pin(name, to: nil, preload: true, integrity: true)
|
|
73
|
+
clear_cache
|
|
74
|
+
@packages[name] = MappedFile.new(name: name, path: to || "#{name}.js", preload: preload, integrity: integrity)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def pin_all_from(dir, under: nil, to: nil, preload: true, integrity: true)
|
|
78
|
+
clear_cache
|
|
79
|
+
@directories[dir] = MappedDir.new(dir: dir, under: under, path: to, preload: preload, integrity: integrity)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns an array of all the resolved module paths of the pinned packages. The `resolver` must respond to
|
|
83
|
+
# `path_to_asset`, such as `ActionController::Base.helpers` or `ApplicationController.helpers`. You'll want to use the
|
|
84
|
+
# resolver that has been configured for the `asset_host` you want these resolved paths to use. In case you need to
|
|
85
|
+
# resolve for different asset hosts, you can pass in a custom `cache_key` to vary the cache used by this method for
|
|
86
|
+
# the different cases.
|
|
87
|
+
def preloaded_module_paths(resolver:, entry_point: "application", cache_key: :preloaded_module_paths)
|
|
88
|
+
preloaded_module_packages(resolver: resolver, entry_point: entry_point, cache_key: cache_key).keys
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Returns a hash of resolved module paths to their corresponding package objects for all pinned packages
|
|
92
|
+
# that are marked for preloading. The hash keys are the resolved asset paths, and the values are the
|
|
93
|
+
# +MappedFile+ objects containing package metadata including name, path, preload setting, and integrity.
|
|
94
|
+
#
|
|
95
|
+
# The +resolver+ must respond to +path_to_asset+, such as +ActionController::Base.helpers+ or
|
|
96
|
+
# +ApplicationController.helpers+. You'll want to use the resolver that has been configured for the
|
|
97
|
+
# +asset_host+ you want these resolved paths to use.
|
|
98
|
+
#
|
|
99
|
+
# ==== Parameters
|
|
100
|
+
#
|
|
101
|
+
# [+resolver+]
|
|
102
|
+
# An object that responds to +path_to_asset+ for resolving asset paths.
|
|
103
|
+
#
|
|
104
|
+
# [+entry_point+]
|
|
105
|
+
# The entry point name or array of entry point names to determine which packages should be preloaded.
|
|
106
|
+
# Defaults to +"application"+. Packages with +preload: true+ are always included regardless of entry point.
|
|
107
|
+
# Packages with specific entry point names (e.g., +preload: "admin"+) are only included when that entry
|
|
108
|
+
# point is specified.
|
|
109
|
+
#
|
|
110
|
+
# [+cache_key+]
|
|
111
|
+
# A custom cache key to vary the cache used by this method for different cases, such as resolving
|
|
112
|
+
# for different asset hosts. Defaults to +:preloaded_module_packages+.
|
|
113
|
+
#
|
|
114
|
+
# ==== Returns
|
|
115
|
+
#
|
|
116
|
+
# A hash where:
|
|
117
|
+
# * Keys are resolved asset paths (strings)
|
|
118
|
+
# * Values are +MappedFile+ objects with +name+, +path+, +preload+, and +integrity+ attributes
|
|
119
|
+
#
|
|
120
|
+
# Missing assets are gracefully handled and excluded from the returned hash.
|
|
121
|
+
#
|
|
122
|
+
# ==== Examples
|
|
123
|
+
#
|
|
124
|
+
# # Get all preloaded packages for the default "application" entry point
|
|
125
|
+
# packages = importmap.preloaded_module_packages(resolver: ApplicationController.helpers)
|
|
126
|
+
# # => { "/assets/application-abc123.js" => #<struct name="application", path="application.js", preload=true, integrity=nil>,
|
|
127
|
+
# # "https://cdn.skypack.dev/react" => #<struct name="react", path="https://cdn.skypack.dev/react", preload=true, integrity="sha384-..."> }
|
|
128
|
+
#
|
|
129
|
+
# # Get preloaded packages for a specific entry point
|
|
130
|
+
# packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: "admin")
|
|
131
|
+
#
|
|
132
|
+
# # Get preloaded packages for multiple entry points
|
|
133
|
+
# packages = importmap.preloaded_module_packages(resolver: helpers, entry_point: ["application", "admin"])
|
|
134
|
+
#
|
|
135
|
+
# # Use a custom cache key for different asset hosts
|
|
136
|
+
# packages = importmap.preloaded_module_packages(resolver: helpers, cache_key: "cdn_host")
|
|
137
|
+
def preloaded_module_packages(resolver:, entry_point: "application", cache_key: :preloaded_module_packages)
|
|
138
|
+
cache_as(cache_key) do
|
|
139
|
+
expanded_preloading_packages_and_directories(entry_point:).filter_map do |_, package|
|
|
140
|
+
resolved_path = resolve_asset_path(package.path, resolver: resolver)
|
|
141
|
+
next unless resolved_path
|
|
142
|
+
|
|
143
|
+
resolved_integrity = resolve_integrity_value(package.integrity, package.path, resolver: resolver)
|
|
144
|
+
|
|
145
|
+
package = MappedFile.new(
|
|
146
|
+
name: package.name,
|
|
147
|
+
path: package.path,
|
|
148
|
+
preload: package.preload,
|
|
149
|
+
integrity: resolved_integrity
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
[resolved_path, package]
|
|
153
|
+
end.to_h
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns a JSON hash (as a string) of all the resolved module paths of the pinned packages in the import map format.
|
|
158
|
+
# The `resolver` must respond to `path_to_asset`, such as `ActionController::Base.helpers` or
|
|
159
|
+
# `ApplicationController.helpers`. You'll want to use the resolver that has been configured for the `asset_host` you
|
|
160
|
+
# want these resolved paths to use. In case you need to resolve for different asset hosts, you can pass in a custom
|
|
161
|
+
# `cache_key` to vary the cache used by this method for the different cases.
|
|
162
|
+
def to_json(resolver:, cache_key: :json)
|
|
163
|
+
cache_as(cache_key) do
|
|
164
|
+
packages = expanded_packages_and_directories
|
|
165
|
+
map = build_import_map(packages, resolver: resolver)
|
|
166
|
+
JSON.pretty_generate(map)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Returns a SHA1 digest of the import map json that can be used as a part of a page etag to
|
|
171
|
+
# ensure that a html cache is invalidated when the import map is changed.
|
|
172
|
+
#
|
|
173
|
+
# Example:
|
|
174
|
+
#
|
|
175
|
+
# class ApplicationController < ActionController::Base
|
|
176
|
+
# etag { Rails.application.importmap.digest(resolver: helpers) if request.format&.html? }
|
|
177
|
+
# end
|
|
178
|
+
def digest(resolver:)
|
|
179
|
+
Digest::SHA1.hexdigest(to_json(resolver: resolver).to_s)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Returns an instance of ActiveSupport::EventedFileUpdateChecker configured to clear the cache of the map
|
|
183
|
+
# when the directories passed on initialization via `watches:` have changes. This is used in development
|
|
184
|
+
# and test to ensure the map caches are reset when javascript files are changed.
|
|
185
|
+
def cache_sweeper(watches: nil)
|
|
186
|
+
if watches
|
|
187
|
+
@cache_sweeper =
|
|
188
|
+
Rails.application.config.file_watcher.new([], Array(watches).collect { |dir| [ dir.to_s, "js"] }.to_h) do
|
|
189
|
+
clear_cache
|
|
190
|
+
end
|
|
191
|
+
else
|
|
192
|
+
@cache_sweeper
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
private
|
|
197
|
+
MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true)
|
|
198
|
+
MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)
|
|
199
|
+
|
|
200
|
+
def cache_as(name)
|
|
201
|
+
if result = @cache[name.to_s]
|
|
202
|
+
result
|
|
203
|
+
else
|
|
204
|
+
@cache[name.to_s] = yield
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def clear_cache
|
|
209
|
+
@cache.clear
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def rescuable_asset_error?(error)
|
|
213
|
+
Rails.application.config.importmap.rescuable_asset_errors.any? { |e| error.is_a?(e) }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def resolve_asset_paths(paths, resolver:)
|
|
217
|
+
paths.transform_values do |mapping|
|
|
218
|
+
resolve_asset_path(mapping.path, resolver:)
|
|
219
|
+
end.compact
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def resolve_asset_path(path, resolver:)
|
|
223
|
+
begin
|
|
224
|
+
resolver.path_to_asset(path)
|
|
225
|
+
rescue => e
|
|
226
|
+
if rescuable_asset_error?(e)
|
|
227
|
+
Rails.logger.warn "Importmap skipped missing path: #{path}"
|
|
228
|
+
nil
|
|
229
|
+
else
|
|
230
|
+
raise e
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def build_import_map(packages, resolver:)
|
|
236
|
+
map = { "imports" => resolve_asset_paths(packages, resolver: resolver) }
|
|
237
|
+
integrity = build_integrity_hash(packages, resolver: resolver)
|
|
238
|
+
map["integrity"] = integrity unless integrity.empty?
|
|
239
|
+
map
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def build_integrity_hash(packages, resolver:)
|
|
243
|
+
packages.filter_map do |name, mapping|
|
|
244
|
+
next unless mapping.integrity
|
|
245
|
+
|
|
246
|
+
resolved_path = resolve_asset_path(mapping.path, resolver: resolver)
|
|
247
|
+
next unless resolved_path
|
|
248
|
+
|
|
249
|
+
integrity_value = resolve_integrity_value(mapping.integrity, mapping.path, resolver: resolver)
|
|
250
|
+
next unless integrity_value
|
|
251
|
+
|
|
252
|
+
[resolved_path, integrity_value]
|
|
253
|
+
end.to_h
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def resolve_integrity_value(integrity, path, resolver:)
|
|
257
|
+
return unless @integrity
|
|
258
|
+
|
|
259
|
+
case integrity
|
|
260
|
+
when true
|
|
261
|
+
resolver.asset_integrity(path) if resolver.respond_to?(:asset_integrity)
|
|
262
|
+
when String
|
|
263
|
+
integrity
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def expanded_preloading_packages_and_directories(entry_point:)
|
|
268
|
+
expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def expanded_packages_and_directories
|
|
272
|
+
@packages.dup.tap { |expanded| expand_directories_into expanded }
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def expand_directories_into(paths)
|
|
276
|
+
@directories.values.each do |mapping|
|
|
277
|
+
if (absolute_path = absolute_root_of(mapping.dir)).exist?
|
|
278
|
+
find_javascript_files_in_tree(absolute_path).each do |filename|
|
|
279
|
+
module_filename = filename.relative_path_from(absolute_path)
|
|
280
|
+
module_name = module_name_from(module_filename, mapping)
|
|
281
|
+
module_path = module_path_from(module_filename, mapping)
|
|
282
|
+
|
|
283
|
+
paths[module_name] = MappedFile.new(
|
|
284
|
+
name: module_name,
|
|
285
|
+
path: module_path,
|
|
286
|
+
preload: mapping.preload,
|
|
287
|
+
integrity: mapping.integrity
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def module_name_from(filename, mapping)
|
|
295
|
+
# Regex explanation:
|
|
296
|
+
# (?:\/|^) # Matches either / OR the start of the string
|
|
297
|
+
# index # Matches the word index
|
|
298
|
+
# $ # Matches the end of the string
|
|
299
|
+
#
|
|
300
|
+
# Sample matches
|
|
301
|
+
# index
|
|
302
|
+
# folder/index
|
|
303
|
+
index_regex = /(?:\/|^)index$/
|
|
304
|
+
|
|
305
|
+
[ mapping.under, filename.to_s.chomp(filename.extname).remove(index_regex).presence ].compact.join("/")
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def module_path_from(filename, mapping)
|
|
309
|
+
[ mapping.path || mapping.under, filename.to_s ].compact.reject(&:empty?).join("/")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def find_javascript_files_in_tree(path)
|
|
313
|
+
Dir[path.join("**/*.js{,m}")].sort.collect { |file| Pathname.new(file) }.select(&:file?)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def absolute_root_of(path)
|
|
317
|
+
(pathname = Pathname.new(path)).absolute? ? pathname : Rails.root.join(path)
|
|
318
|
+
end
|
|
319
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
require "tmpdir"
|
|
3
|
+
|
|
4
|
+
# Minifies a vendored package with whichever JavaScript minifier is installed:
|
|
5
|
+
# bun, esbuild or terser, looked up in node_modules/.bin and then on PATH.
|
|
6
|
+
# Every tool runs in transform-only mode, so bare import specifiers are left
|
|
7
|
+
# exactly as the CDN resolved them and the import map keeps working.
|
|
8
|
+
class Importmap::Minifier
|
|
9
|
+
Error = Class.new(StandardError)
|
|
10
|
+
|
|
11
|
+
TOOLS = {
|
|
12
|
+
"bun" => ->(input, output) { [ "build", "--no-bundle", "--minify", "--format=esm", "--target=browser", input, "--outfile=#{output}" ] },
|
|
13
|
+
"esbuild" => ->(input, output) { [ input, "--minify", "--format=esm", "--outfile=#{output}" ] },
|
|
14
|
+
"terser" => ->(input, output) { [ input, "--module", "--compress", "--mangle", "--output", output ] }
|
|
15
|
+
}.freeze # :nodoc:
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def detect
|
|
19
|
+
TOOLS.keys.find { |tool| executable_for(tool) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def available?
|
|
23
|
+
!detect.nil?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def executable_for(tool)
|
|
27
|
+
directories = [ File.expand_path(File.join("node_modules", ".bin")), *ENV["PATH"].to_s.split(File::PATH_SEPARATOR) ]
|
|
28
|
+
|
|
29
|
+
directories.each do |directory|
|
|
30
|
+
command_extensions.each do |extension|
|
|
31
|
+
candidate = File.join(directory, "#{tool}#{extension}")
|
|
32
|
+
return candidate if File.file?(candidate) && File.executable?(candidate)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# On Windows npm installs these tools as .cmd shims, so the bare name
|
|
40
|
+
# never resolves. Elsewhere the extensionless name is the only candidate.
|
|
41
|
+
def command_extensions
|
|
42
|
+
return [ "" ] unless Gem.win_platform?
|
|
43
|
+
|
|
44
|
+
[ "", *ENV.fetch("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(";") ]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :tool
|
|
49
|
+
|
|
50
|
+
# Pass a tool name to force one, or nothing to use the first one found.
|
|
51
|
+
def initialize(tool = :auto)
|
|
52
|
+
@tool = tool == :auto ? self.class.detect : tool&.to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def call(source)
|
|
56
|
+
executable = tool && self.class.executable_for(tool)
|
|
57
|
+
|
|
58
|
+
unless executable
|
|
59
|
+
raise Error, "No JavaScript minifier found: install bun, esbuild or terser (globally or in node_modules/.bin)"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Dir.mktmpdir("importmap-minify") do |directory|
|
|
63
|
+
input = File.join(directory, "package.js")
|
|
64
|
+
output = File.join(directory, "package.min.js")
|
|
65
|
+
File.write(input, source)
|
|
66
|
+
|
|
67
|
+
_stdout, stderr, status = Open3.capture3(executable, *TOOLS.fetch(tool).call(input, output))
|
|
68
|
+
|
|
69
|
+
unless status.success? && File.exist?(output)
|
|
70
|
+
raise Error, "#{tool} failed to minify: #{stderr.strip}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
File.read(output)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "json"
|
|
4
|
+
require "importmap/http_retries"
|
|
5
|
+
|
|
6
|
+
class Importmap::Npm
|
|
7
|
+
include Importmap::HttpRetries
|
|
8
|
+
|
|
9
|
+
PIN_REGEX = /#{Importmap::Map::PIN_REGEX}.*/.freeze # :nodoc:
|
|
10
|
+
|
|
11
|
+
Error = Class.new(StandardError)
|
|
12
|
+
HTTPError = Class.new(Error)
|
|
13
|
+
|
|
14
|
+
singleton_class.attr_accessor :base_uri
|
|
15
|
+
self.base_uri = URI("https://registry.npmjs.org")
|
|
16
|
+
|
|
17
|
+
def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript")
|
|
18
|
+
@importmap_path = Pathname.new(importmap_path)
|
|
19
|
+
@vendor_path = Pathname.new(vendor_path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def outdated_packages
|
|
23
|
+
packages_with_versions.each_with_object([]) do |(package, current_version), outdated_packages|
|
|
24
|
+
outdated_package = OutdatedPackage.new(name: package, current_version: current_version)
|
|
25
|
+
|
|
26
|
+
if !(response = get_package(package))
|
|
27
|
+
outdated_package.error = 'Response error'
|
|
28
|
+
elsif (error = response['error'])
|
|
29
|
+
outdated_package.error = error
|
|
30
|
+
else
|
|
31
|
+
latest_version = find_latest_version(response)
|
|
32
|
+
next unless outdated?(current_version, latest_version)
|
|
33
|
+
|
|
34
|
+
outdated_package.latest_version = latest_version
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
outdated_packages << outdated_package
|
|
38
|
+
end.sort_by(&:name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def vulnerable_packages
|
|
42
|
+
get_audit.flat_map do |package, vulnerabilities|
|
|
43
|
+
vulnerabilities.map do |vulnerability|
|
|
44
|
+
VulnerablePackage.new(
|
|
45
|
+
name: package,
|
|
46
|
+
severity: vulnerability['severity'],
|
|
47
|
+
vulnerable_versions: vulnerability['vulnerable_versions'],
|
|
48
|
+
vulnerability: vulnerability['title']
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end.sort_by { |p| [p.name, p.severity] }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def packages_with_versions
|
|
55
|
+
# We cannot use the name after "pin" because some dependencies are loaded from inside packages
|
|
56
|
+
# Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.19/nodelibs/browser/buffer.js"
|
|
57
|
+
with_versions = importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/|esm\.sh\/|esm\.sh\/\*)([^@\/]+)@(\d+\.\d+\.\d+(?:[^\/\s"']*))/) |
|
|
58
|
+
importmap.scan(/#{PIN_REGEX} #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
|
|
59
|
+
|
|
60
|
+
with_versions.map! do |package, version|
|
|
61
|
+
[extract_base_package_name(package), version]
|
|
62
|
+
end.uniq!
|
|
63
|
+
|
|
64
|
+
vendored_packages_without_version(with_versions).each do |package, path|
|
|
65
|
+
$stdout.puts "Ignoring #{package} (#{path}) since no version is specified in the importmap"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
with_versions
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
OutdatedPackage = Struct.new(:name, :current_version, :latest_version, :error, keyword_init: true)
|
|
73
|
+
VulnerablePackage = Struct.new(:name, :severity, :vulnerable_versions, :vulnerability, keyword_init: true)
|
|
74
|
+
|
|
75
|
+
def importmap
|
|
76
|
+
@importmap ||= File.read(@importmap_path)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get_package(package)
|
|
80
|
+
uri = self.class.base_uri.dup
|
|
81
|
+
uri.path = "/" + package
|
|
82
|
+
response = get_json(uri)
|
|
83
|
+
|
|
84
|
+
JSON.parse(response)
|
|
85
|
+
rescue JSON::ParserError
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def get_json(uri)
|
|
90
|
+
request = Net::HTTP::Get.new(uri)
|
|
91
|
+
request["Content-Type"] = "application/json"
|
|
92
|
+
|
|
93
|
+
response = begin
|
|
94
|
+
with_retries("fetching #{uri}") do
|
|
95
|
+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http|
|
|
96
|
+
http.request(request)
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
rescue HTTPError
|
|
100
|
+
raise
|
|
101
|
+
rescue => error
|
|
102
|
+
raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
unless response.code.to_i < 300
|
|
106
|
+
raise HTTPError, "Unexpected error response #{response.code}: #{response.body}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
response.body
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def find_latest_version(response)
|
|
113
|
+
latest_version = response.is_a?(String) ? response : response.dig('dist-tags', 'latest')
|
|
114
|
+
return latest_version if latest_version
|
|
115
|
+
|
|
116
|
+
return unless response['versions']
|
|
117
|
+
|
|
118
|
+
response['versions'].keys.map { |v| Gem::Version.new(v) rescue nil }.compact.sort.last
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def outdated?(current_version, latest_version)
|
|
122
|
+
Gem::Version.new(current_version) < Gem::Version.new(latest_version)
|
|
123
|
+
rescue ArgumentError
|
|
124
|
+
current_version.to_s < latest_version.to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def get_audit
|
|
128
|
+
uri = self.class.base_uri.dup
|
|
129
|
+
uri.path = "/-/npm/v1/security/advisories/bulk"
|
|
130
|
+
|
|
131
|
+
body = packages_with_versions.each.with_object({}) { |(package, version), data|
|
|
132
|
+
data[package] ||= []
|
|
133
|
+
data[package] << version
|
|
134
|
+
}
|
|
135
|
+
return {} if body.empty?
|
|
136
|
+
|
|
137
|
+
response = post_json(uri, body)
|
|
138
|
+
|
|
139
|
+
unless response.code.to_i < 300
|
|
140
|
+
raise HTTPError, "Unexpected error response #{response.code}: #{response.body}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
JSON.parse(response.body)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def post_json(uri, body)
|
|
147
|
+
with_retries("posting to #{uri}") do
|
|
148
|
+
Net::HTTP.post(uri, body.to_json, "Content-Type" => "application/json")
|
|
149
|
+
end
|
|
150
|
+
rescue HTTPError
|
|
151
|
+
raise
|
|
152
|
+
rescue => error
|
|
153
|
+
raise HTTPError, "Unexpected transport error (#{error.class}: #{error.message})"
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def extract_base_package_name(package)
|
|
157
|
+
if package.start_with?("@")
|
|
158
|
+
# Scoped packages can have nested paths, e.g. @scope/package/subpath
|
|
159
|
+
parts = package.split("/", 3)
|
|
160
|
+
parts.size > 2 ? parts.first(2).join("/") : package
|
|
161
|
+
else
|
|
162
|
+
# Non-scoped packages - just take the first part
|
|
163
|
+
package.split("/").first
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def vendored_packages_without_version(packages_with_versions)
|
|
168
|
+
versioned_packages = packages_with_versions.map(&:first).to_set
|
|
169
|
+
|
|
170
|
+
importmap
|
|
171
|
+
.lines
|
|
172
|
+
.filter_map { |line| find_unversioned_vendored_package(line, versioned_packages) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def find_unversioned_vendored_package(line, versioned_packages)
|
|
176
|
+
regexp = line.include?("to:")? /#{PIN_REGEX}to: ["']([^"']*)["'].*/ : PIN_REGEX
|
|
177
|
+
match = line.match(regexp)
|
|
178
|
+
|
|
179
|
+
return unless match
|
|
180
|
+
|
|
181
|
+
package, filename = match.captures
|
|
182
|
+
filename ||= "#{package}.js"
|
|
183
|
+
|
|
184
|
+
return if versioned_packages.include?(package)
|
|
185
|
+
|
|
186
|
+
path = File.join(@vendor_path, filename)
|
|
187
|
+
[package, path] if File.exist?(path)
|
|
188
|
+
end
|
|
189
|
+
end
|