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,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