importmap-plus 1.1.1 → 2.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 +4 -4
- data/CHANGELOG.md +351 -0
- data/README.md +10 -2
- data/app/helpers/importmap/importmap_tags_helper.rb +3 -0
- data/lib/importmap/batch_resolver.rb +133 -0
- data/lib/importmap/commands.rb +333 -32
- data/lib/importmap/doctor.rb +331 -0
- data/lib/importmap/early_hints.rb +50 -0
- data/lib/importmap/engine.rb +3 -0
- data/lib/importmap/esm_run.rb +118 -0
- data/lib/importmap/graph.rb +126 -0
- data/lib/importmap/import_scanner.rb +53 -0
- data/lib/importmap/integrity.rb +25 -0
- data/lib/importmap/map.rb +38 -1
- data/lib/importmap/module_inspector.rb +200 -0
- data/lib/importmap/npm.rb +9 -0
- data/lib/importmap/package_graph.rb +299 -0
- data/lib/importmap/packager.rb +302 -110
- data/lib/importmap/provider_chain.rb +89 -0
- data/lib/importmap/vendored_graph.rb +214 -0
- data/lib/importmap/version.rb +1 -1
- metadata +12 -1
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "uri"
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "active_support/core_ext/array/conversions"
|
|
5
|
+
require "active_support/core_ext/string/inflections"
|
|
6
|
+
require "importmap/http_retries"
|
|
7
|
+
require "importmap/import_scanner"
|
|
8
|
+
require "importmap/integrity"
|
|
9
|
+
require "importmap/module_inspector"
|
|
10
|
+
require "importmap/packager"
|
|
11
|
+
require "importmap/package_graph"
|
|
12
|
+
require "importmap/vendored_graph"
|
|
13
|
+
|
|
14
|
+
# Everything that can be wrong with an app's import map without anything
|
|
15
|
+
# saying so until a page fails in the browser: a pin whose file isn't there, a
|
|
16
|
+
# vendored file importing a bare specifier nobody pinned, a vendored file that
|
|
17
|
+
# still needs the siblings it was downloaded beside, a CommonJS bundle a CDN
|
|
18
|
+
# handed back, two pins serving one file, a file left in vendor/javascript that
|
|
19
|
+
# nothing maps.
|
|
20
|
+
#
|
|
21
|
+
# It reports; it never fixes. `pin`, `unpin` and `pin --vendor` are what fix
|
|
22
|
+
# things, and a doctor that edited config/importmap.rb would be a doctor nobody
|
|
23
|
+
# could run in CI.
|
|
24
|
+
#
|
|
25
|
+
# Offline by default: every check but the last reads the map, the asset paths
|
|
26
|
+
# and the files on disk. +online+ adds the one check that can't be made without
|
|
27
|
+
# the network — that a remote pin is still served, and still serves the bytes
|
|
28
|
+
# its integrity hash was computed from.
|
|
29
|
+
class Importmap::Doctor
|
|
30
|
+
include Importmap::HttpRetries
|
|
31
|
+
|
|
32
|
+
Error = Class.new(StandardError)
|
|
33
|
+
HTTPError = Class.new(Error)
|
|
34
|
+
|
|
35
|
+
# Wide enough for "warning" plus the space that separates every level from
|
|
36
|
+
# its message, so the messages line up under each other.
|
|
37
|
+
LEVEL_WIDTH = 8 # :nodoc:
|
|
38
|
+
|
|
39
|
+
Finding = Struct.new(:level, :message) do # :nodoc:
|
|
40
|
+
def to_s
|
|
41
|
+
"#{level.to_s.ljust(LEVEL_WIDTH)} #{message}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# +path+ is the logical asset path or the URL the key maps to, +file+ the
|
|
46
|
+
# file on disk that serves it, or nil when no asset path holds one.
|
|
47
|
+
Entry = Struct.new(:name, :path, :integrity, :file, keyword_init: true) # :nodoc:
|
|
48
|
+
|
|
49
|
+
# Where `pin --vendor` and a vendored graph write, and so the only directory
|
|
50
|
+
# whose contents this gem is entitled to have an opinion about.
|
|
51
|
+
VENDOR_PATH = "vendor/javascript".freeze # :nodoc:
|
|
52
|
+
|
|
53
|
+
# Importmap::Map's directory glob is `**/*.js{,m}` — .js and .jsm, not .mjs.
|
|
54
|
+
# This one takes .mjs too, on purpose: a .mjs under a pin_all_from directory
|
|
55
|
+
# is precisely the file the map can never see, and saying so is the point.
|
|
56
|
+
VENDORED_GLOB = "**/*.{js,jsm,mjs}".freeze # :nodoc:
|
|
57
|
+
|
|
58
|
+
# A specifier the import map has to define. Anything the browser resolves
|
|
59
|
+
# against the importing file's own URL, or fetches outright, is not one.
|
|
60
|
+
SCHEME_REGEXP = %r{\A[a-zA-Z][a-zA-Z0-9+\-.]*:}.freeze # :nodoc:
|
|
61
|
+
|
|
62
|
+
def initialize(importmap:, resolver:, root:, asset_paths: [], online: false)
|
|
63
|
+
@importmap = importmap
|
|
64
|
+
@resolver = resolver
|
|
65
|
+
@root = Pathname.new(root)
|
|
66
|
+
@asset_paths = Array(asset_paths).map { |path| Pathname.new(path) }
|
|
67
|
+
@online = online
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Every finding, errors before warnings within each check and the checks in
|
|
71
|
+
# the order they are defined below.
|
|
72
|
+
def diagnose
|
|
73
|
+
@diagnose ||= [
|
|
74
|
+
*unresolvable_pins,
|
|
75
|
+
*unpinned_specifiers,
|
|
76
|
+
*stray_relative_imports,
|
|
77
|
+
*non_modules,
|
|
78
|
+
*unserved_vendored_files,
|
|
79
|
+
*keys_sharing_a_path,
|
|
80
|
+
*packages_vendored_twice,
|
|
81
|
+
*(@online ? unreachable_remote_pins : [])
|
|
82
|
+
]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def errors?
|
|
86
|
+
diagnose.any? { |finding| finding.level == :error }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def summary
|
|
90
|
+
errors, warnings = diagnose.partition { |finding| finding.level == :error }
|
|
91
|
+
|
|
92
|
+
"#{errors.size} #{"error".pluralize(errors.size)}, #{warnings.size} #{"warning".pluralize(warnings.size)}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
def unresolvable_pins
|
|
97
|
+
entries.reject { |entry| remote?(entry.path) }.filter_map do |entry|
|
|
98
|
+
error(%(pin "#{entry.name}" → #{entry.path}: no such asset)) unless resolves?(entry.path)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def unpinned_specifiers
|
|
103
|
+
scannable.flat_map do |entry|
|
|
104
|
+
imports_in(entry.file).filter_map do |import|
|
|
105
|
+
next unless bare?(import.specifier)
|
|
106
|
+
next if pinned?(import.specifier)
|
|
107
|
+
|
|
108
|
+
error(%(#{relative(entry.file)} imports "#{import.specifier}", which isn't pinned))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A vendored file's relative imports were rewritten to the keys of the
|
|
114
|
+
# files vendored beside it, so one left over points at a file that isn't
|
|
115
|
+
# there: the download predates the fork learning to vendor a file graph, or
|
|
116
|
+
# the graph directory has since been removed.
|
|
117
|
+
def stray_relative_imports
|
|
118
|
+
vendored.flat_map do |entry|
|
|
119
|
+
imports_in(entry.file).filter_map do |import|
|
|
120
|
+
next unless relative?(import.specifier)
|
|
121
|
+
next if sibling_of(entry.file, import.specifier)&.file?
|
|
122
|
+
|
|
123
|
+
error(%(#{relative(entry.file)} imports "#{import.specifier}" by relative path — ) +
|
|
124
|
+
%(run bin/importmap pin #{vendored_package_of(entry.file) || entry.name} to vendor its files))
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def non_modules
|
|
130
|
+
vendored.filter_map do |entry|
|
|
131
|
+
next if Importmap::ModuleInspector.new(source_of(entry.file)).es_module?
|
|
132
|
+
|
|
133
|
+
error("#{relative(entry.file)} isn't an ES module")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def unserved_vendored_files
|
|
138
|
+
(vendored_files - entries.filter_map(&:file)).map do |file|
|
|
139
|
+
warning("#{relative(file)} isn't pinned by anything")
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Two keys on one file are two modules in the browser, each with its own
|
|
144
|
+
# state, and whichever one a package imports is the one the other half of
|
|
145
|
+
# the app isn't using.
|
|
146
|
+
def keys_sharing_a_path
|
|
147
|
+
entries.group_by(&:path).filter_map do |path, sharing|
|
|
148
|
+
next unless sharing.size > 1
|
|
149
|
+
|
|
150
|
+
names = sharing.map { |entry| %("#{entry.name}") }
|
|
151
|
+
warning("#{names.to_sentence} #{all_or_both(names)} resolve to #{path}")
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def packages_vendored_twice
|
|
156
|
+
vendored_versions.group_by { |_file, package, _version| package }.filter_map do |package, group|
|
|
157
|
+
versions = group.map { |_file, _package, version| version }
|
|
158
|
+
next if versions.uniq.size < 2
|
|
159
|
+
|
|
160
|
+
files = group.map { |file, _package, _version| relative(file) }
|
|
161
|
+
warning("#{files.to_sentence} #{all_or_both(files)} vendor #{package}, at #{versions.to_sentence}")
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def unreachable_remote_pins
|
|
166
|
+
entries.select { |entry| remote?(entry.path) }.filter_map { |entry| remote_finding_for(entry) }
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def remote_finding_for(entry)
|
|
170
|
+
response = with_retries("fetching #{entry.path}") { Net::HTTP.get_response(URI(entry.path)) }
|
|
171
|
+
|
|
172
|
+
if response.code != "200"
|
|
173
|
+
error(%(pin "#{entry.name}" → #{entry.path}: the CDN answered #{response.code}))
|
|
174
|
+
elsif Importmap::Integrity.hash?(entry.integrity) && entry.integrity != Importmap::Integrity.for(response.body)
|
|
175
|
+
error(%(pin "#{entry.name}" → #{entry.path}: integrity doesn't match what the CDN served))
|
|
176
|
+
end
|
|
177
|
+
rescue HTTPError => e
|
|
178
|
+
# The reason with_retries gives already names the URL, so the finding
|
|
179
|
+
# doesn't name it a second time.
|
|
180
|
+
error(%(pin "#{entry.name}": #{e.message}))
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def entries
|
|
184
|
+
@entries ||= @importmap.each_expanded_package.map do |name, mapping|
|
|
185
|
+
Entry.new(name: name, path: mapping.path, integrity: mapping.integrity, file: file_for(mapping.path))
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# The files this gem may read: the app's own, not a file an engine serves
|
|
190
|
+
# out of its gem, which no `bin/importmap` command can do anything about.
|
|
191
|
+
# One file serving two keys is scanned once.
|
|
192
|
+
def scannable
|
|
193
|
+
@scannable ||= entries.select { |entry| entry.file && under?(entry.file, @root) }.uniq(&:file)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def vendored
|
|
197
|
+
@vendored ||= scannable.select { |entry| under?(entry.file, @root.join(VENDOR_PATH)) }
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def vendored_files
|
|
201
|
+
@vendored_files ||= Pathname.glob(@root.join(VENDOR_PATH, VENDORED_GLOB)).select(&:file?).sort
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The package and version every vendored file's header names, read out of
|
|
205
|
+
# the CDN URL it was downloaded from — the npm identity, which is what says
|
|
206
|
+
# two pins hold one package, rather than the key, which is whatever the app
|
|
207
|
+
# typed. A file with no header, or from a CDN whose URLs don't spell the
|
|
208
|
+
# package out, simply isn't in the answer.
|
|
209
|
+
def vendored_versions
|
|
210
|
+
@vendored_versions ||= vendored_files.filter_map do |file|
|
|
211
|
+
url = header_url_of(file)
|
|
212
|
+
package, version = Importmap::PackageGraph.package_and_version_for(url) if url
|
|
213
|
+
|
|
214
|
+
[ file, package, version ] if package && version
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# The files a graph vendored carry no header of their own — the entry they
|
|
219
|
+
# were downloaded beside does, and Importmap::Packager names their
|
|
220
|
+
# directory after that entry's file. So a sibling asks the entry what
|
|
221
|
+
# package it belongs to, and the hint names something `bin/importmap pin`
|
|
222
|
+
# can act on rather than the key of the one file that went missing.
|
|
223
|
+
def vendored_package_of(file)
|
|
224
|
+
vendored_packages[file] || vendored_packages[graph_entry_for(file)]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def vendored_packages
|
|
228
|
+
@vendored_packages ||= vendored_versions.to_h { |file, package, _version| [ file, package ] }
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def graph_entry_for(file)
|
|
232
|
+
segments = file.relative_path_from(@root.join(VENDOR_PATH)).each_filename.to_a
|
|
233
|
+
return if segments.size < 2
|
|
234
|
+
|
|
235
|
+
@root.join(VENDOR_PATH, "#{segments.first}.js")
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def header_url_of(file)
|
|
239
|
+
File.open(file, "rb") { |io| io.gets.to_s }[Importmap::VendoredGraph::DOWNLOADED_FROM_REGEXP, 1]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def file_for(path)
|
|
243
|
+
return if remote?(path)
|
|
244
|
+
|
|
245
|
+
@asset_paths.lazy.map { |root| root.join(path) }.find(&:file?)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def resolves?(path)
|
|
249
|
+
@resolver.path_to_asset(path)
|
|
250
|
+
true
|
|
251
|
+
rescue => error
|
|
252
|
+
raise error unless rescuable_asset_error?(error)
|
|
253
|
+
|
|
254
|
+
false
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# The same list Importmap::Map rescues on the request path, so a pin the
|
|
258
|
+
# map would quietly skip is the pin this reports.
|
|
259
|
+
def rescuable_asset_error?(error)
|
|
260
|
+
Rails.application.config.importmap.rescuable_asset_errors.any? { |klass| error.is_a?(klass) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def imports_in(file)
|
|
264
|
+
Importmap::ImportScanner.new(source_of(file)).imports
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Read as bytes and scrubbed: a vendored file this gem didn't write, or
|
|
268
|
+
# wrote before it learned to ask CDNs for a body it can read, can hold a
|
|
269
|
+
# sequence no UTF-8 regexp will match without raising, and one such file
|
|
270
|
+
# must not stop every other check.
|
|
271
|
+
def source_of(file)
|
|
272
|
+
@sources ||= {}
|
|
273
|
+
@sources[file] ||= File.binread(file).force_encoding(Encoding::UTF_8).scrub
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A specifier the map resolves: a key it defines outright, a key ending in
|
|
277
|
+
# "/" that covers everything under it, or — leniently — a key the specifier
|
|
278
|
+
# is a subpath of. That last one is not what a browser does, which needs
|
|
279
|
+
# the trailing-slash key. It is here because the alternative is an error on
|
|
280
|
+
# every subpath of every package vendored with its file graph, whose keys
|
|
281
|
+
# this gem writes one by one, and a check that cries wolf is a check nobody
|
|
282
|
+
# runs. A missed subpath still fails in the browser exactly as loudly as it
|
|
283
|
+
# did before anyone ran the doctor.
|
|
284
|
+
def pinned?(specifier)
|
|
285
|
+
keys.any? do |key|
|
|
286
|
+
key == specifier || (key.end_with?("/") ? specifier.start_with?(key) : specifier.start_with?("#{key}/"))
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def keys
|
|
291
|
+
@keys ||= entries.map(&:name)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def bare?(specifier)
|
|
295
|
+
!specifier.empty? && !relative?(specifier) && !specifier.start_with?("/") && !specifier.match?(SCHEME_REGEXP)
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def relative?(specifier)
|
|
299
|
+
specifier.start_with?("./", "../")
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def remote?(path)
|
|
303
|
+
path.match?(Importmap::Packager::REMOTE_URL_REGEXP)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def sibling_of(file, specifier)
|
|
307
|
+
file.dirname.join(specifier.sub(/[?#].*\z/, ""))
|
|
308
|
+
rescue ArgumentError
|
|
309
|
+
nil
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def under?(file, directory)
|
|
313
|
+
file.to_s.start_with?("#{directory}/")
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def relative(file)
|
|
317
|
+
file.relative_path_from(@root)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def all_or_both(items)
|
|
321
|
+
items.size > 2 ? "all" : "both"
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def error(message)
|
|
325
|
+
Finding.new(:error, message)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def warning(message)
|
|
329
|
+
Finding.new(:warning, message)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# The modulepreload links of javascript_importmap_tags, sent ahead of the
|
|
2
|
+
# response as 103 Early Hints.
|
|
3
|
+
#
|
|
4
|
+
# A link in the body can only be acted on once the browser has parsed that far
|
|
5
|
+
# into the HTML, so the whole module graph waits on the response. The same list
|
|
6
|
+
# sent as a Link header before the response starts lets the fetches begin while
|
|
7
|
+
# the app is still rendering — which is what Rails' own javascript_include_tag
|
|
8
|
+
# and stylesheet_link_tag already do for their assets.
|
|
9
|
+
#
|
|
10
|
+
# `request.send_early_hints` is `env["rack.early_hints"]&.call(links)`: a no-op
|
|
11
|
+
# unless the server put the callable there (Puma does with `early_hints true`),
|
|
12
|
+
# so on a server without it this costs one joined string and nothing else.
|
|
13
|
+
module Importmap::EarlyHints
|
|
14
|
+
class << self
|
|
15
|
+
# `view` is the view rendering the tags — the request and response are read
|
|
16
|
+
# off it the way ActionView's own send_preload_links_header reads them, so a
|
|
17
|
+
# context that has neither is left alone. `packages` is a
|
|
18
|
+
# preloaded_module_packages hash, resolved asset path to the package behind
|
|
19
|
+
# it, which makes the hinted set exactly the tags' set.
|
|
20
|
+
def send_modulepreload_links(view, packages)
|
|
21
|
+
return if packages.empty? || !enabled? || sending?(view)
|
|
22
|
+
|
|
23
|
+
request = view.request if view.respond_to?(:request)
|
|
24
|
+
return unless request.respond_to?(:send_early_hints)
|
|
25
|
+
|
|
26
|
+
request.send_early_hints("link" => link_header_for(packages.keys))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
# No integrity parameter: browsers don't honour one on a Link header, and
|
|
31
|
+
# the modulepreload tag in the body still carries it, which is where the
|
|
32
|
+
# hash has to match anyway.
|
|
33
|
+
def link_header_for(paths)
|
|
34
|
+
paths.collect { |path| "<#{path}>; rel=modulepreload" }.join(", ")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A 103 is a response of its own, written to the socket before the real
|
|
38
|
+
# one. Under `render stream: true` the layout renders while the 200 is
|
|
39
|
+
# already going out, and a hint sent then lands in the middle of the body.
|
|
40
|
+
def sending?(view)
|
|
41
|
+
return false unless view.respond_to?(:response)
|
|
42
|
+
|
|
43
|
+
(response = view.response).respond_to?(:sending?) && response.sending?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def enabled?
|
|
47
|
+
Rails.application.config.importmap.early_hints
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/importmap/engine.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "importmap/map"
|
|
2
|
+
require "importmap/early_hints"
|
|
2
3
|
|
|
3
4
|
# Use Rails.application.importmap to access the map
|
|
4
5
|
Rails::Application.send(:attr_accessor, :importmap)
|
|
@@ -10,6 +11,8 @@ module Importmap
|
|
|
10
11
|
config.importmap.sweep_cache = Rails.env.development? || Rails.env.test?
|
|
11
12
|
config.importmap.cache_sweepers = []
|
|
12
13
|
config.importmap.rescuable_asset_errors = []
|
|
14
|
+
config.importmap.preload_strategy = :all
|
|
15
|
+
config.importmap.early_hints = true
|
|
13
16
|
|
|
14
17
|
config.autoload_once_paths = %W( #{root}/app/helpers #{root}/app/controllers )
|
|
15
18
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "json"
|
|
3
|
+
|
|
4
|
+
# Everything this gem knows about jsDelivr's bundling endpoint
|
|
5
|
+
# (https://www.jsdelivr.com/esm): one minified ESM file per package, with its
|
|
6
|
+
# dependencies referenced as /npm/dep@ver/+esm.
|
|
7
|
+
#
|
|
8
|
+
# It is the one provider whose URLs a CDN host can't identify — an esm.run
|
|
9
|
+
# bundle and a plain jsDelivr file are both served from cdn.jsdelivr.net — and
|
|
10
|
+
# the one whose downloads have to be rewritten before they can be vendored, so
|
|
11
|
+
# both facts live here rather than in Importmap::Packager, which asks this
|
|
12
|
+
# class the two questions it has: is this that provider, and is this one of its
|
|
13
|
+
# URLs.
|
|
14
|
+
#
|
|
15
|
+
# Nothing here writes a file or reads config/importmap.rb. The version lookup
|
|
16
|
+
# is the one request, and it goes out through the Packager so a jsDelivr that
|
|
17
|
+
# rate-limits or resets gets the same bounded retries as every other request
|
|
18
|
+
# this gem makes, and fails as the same Importmap::Packager::HTTPError.
|
|
19
|
+
class Importmap::EsmRun
|
|
20
|
+
PROVIDER = "esm.run".freeze # :nodoc:
|
|
21
|
+
CDN = "https://cdn.jsdelivr.net/npm/".freeze # :nodoc:
|
|
22
|
+
URL_REGEXP = %r{\Ahttps://cdn\.jsdelivr\.net/npm/.+/\+esm\z}.freeze # :nodoc:
|
|
23
|
+
# A bundle's own imports: `from"/npm/dep@1.2.3/+esm"`, `import"…"`,
|
|
24
|
+
# `import("…")`, `export … from"…"`. Anchored on the keyword so an ordinary
|
|
25
|
+
# string that happens to look like a bundle URL is left alone. What it does
|
|
26
|
+
# not do is parse JavaScript, so the same text inside a string or a comment
|
|
27
|
+
# would still be rewritten — a jsDelivr bundle is esbuild output whose only
|
|
28
|
+
# surviving comment is the banner, and a root-relative /npm/ URL is
|
|
29
|
+
# meaningless anywhere but in one of its own imports.
|
|
30
|
+
IMPORT_REGEXP =
|
|
31
|
+
%r{((?:\bfrom|\bimport)\s*\(?\s*)(["'])/npm/((?:@[^/"'@]+/)?[^/"'@]+)@([^/"']+)((?:/[^"']*?)?)/\+esm\2}.freeze # :nodoc:
|
|
32
|
+
|
|
33
|
+
# The jsDelivr data API versions are resolved through. Also readable and
|
|
34
|
+
# 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
|
|
37
|
+
self.resolver = URI("https://data.jsdelivr.com/v1/packages/npm/")
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
def provider?(provider)
|
|
41
|
+
provider.to_s == PROVIDER
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def url?(url)
|
|
45
|
+
url.to_s.match?(URL_REGEXP)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def url_for(name, version, subpath = nil)
|
|
49
|
+
"#{CDN}#{name}@#{version}#{subpath}/+esm"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Turns import "/npm/dep@1.2.3/+esm" into import "dep" so the bundle
|
|
53
|
+
# resolves through the import map, and lists what it needs pinned as
|
|
54
|
+
# [ [ package, url ], … ].
|
|
55
|
+
def rewrite_imports(source)
|
|
56
|
+
dependencies = {}
|
|
57
|
+
versions = Hash.new { |hash, key| hash[key] = [] }
|
|
58
|
+
|
|
59
|
+
rewritten = source.gsub(IMPORT_REGEXP) do
|
|
60
|
+
keyword, quote, name, version, subpath = $1, $2, $3, $4, $5.to_s
|
|
61
|
+
key = "#{name}#{subpath}"
|
|
62
|
+
dependencies[key] ||= url_for(name, version, subpath)
|
|
63
|
+
versions[key] << version unless versions[key].include?(version)
|
|
64
|
+
"#{keyword}#{quote}#{name}#{subpath}#{quote}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
warn_about_conflicting_versions(versions)
|
|
68
|
+
|
|
69
|
+
[ rewritten, dependencies.to_a ]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
# An import map maps a bare specifier to one file, so a bundle that
|
|
74
|
+
# imports the same package at two versions can only get the first one it
|
|
75
|
+
# asked for. Say so rather than pick silently.
|
|
76
|
+
def warn_about_conflicting_versions(versions)
|
|
77
|
+
versions.each do |key, seen|
|
|
78
|
+
next if seen.one?
|
|
79
|
+
|
|
80
|
+
warn %(#{key} is imported at #{seen.join(", ")} by this bundle; pinning @#{seen.first}, an import map holds one version)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize(packager)
|
|
86
|
+
@packager = packager
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# The import map Importmap::Packager#import answers with, built from the
|
|
90
|
+
# version jsDelivr resolves each spec to; nil when it hasn't got one of them,
|
|
91
|
+
# because a bundle URL for a version nobody published is a 404 at download.
|
|
92
|
+
def imports(specs)
|
|
93
|
+
imports = Array(specs).to_h do |spec|
|
|
94
|
+
name, requested, subpath = spec.to_s.match(Importmap::Packager::PACKAGE_SPEC_REGEXP)&.captures
|
|
95
|
+
raise Importmap::Packager::Error, "Can't parse package spec #{spec.inspect}" unless name
|
|
96
|
+
|
|
97
|
+
version = resolve_version(name, requested)
|
|
98
|
+
return nil unless version
|
|
99
|
+
|
|
100
|
+
[ "#{name}#{subpath}", self.class.url_for(name, version, subpath) ]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
{ imports: imports }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
def resolve_version(name, requested)
|
|
108
|
+
uri = self.class.resolver.dup
|
|
109
|
+
uri.path += "#{name}/resolved"
|
|
110
|
+
uri.query = "specifier=#{URI.encode_www_form_component(requested)}" if requested
|
|
111
|
+
|
|
112
|
+
body = @packager.fetch_remote(uri, allow_missing: true, description: "resolving #{uri}")
|
|
113
|
+
|
|
114
|
+
body && JSON.parse(body)["version"]
|
|
115
|
+
rescue JSON::ParserError
|
|
116
|
+
raise Importmap::Packager::HTTPError, "Unexpected response from #{uri}"
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
require "pathname"
|
|
2
|
+
require "set"
|
|
3
|
+
require "importmap/import_scanner"
|
|
4
|
+
|
|
5
|
+
# Which pins an entry point actually reaches, read out of the files the map
|
|
6
|
+
# already lists. An import map says what every key resolves to; it says nothing
|
|
7
|
+
# about which keys a given page loads, and so `preload: true` preloads
|
|
8
|
+
# everything on every page. The graph is the missing half: `application.js`
|
|
9
|
+
# imports some of the map, those files import more of it, and the rest is only
|
|
10
|
+
# ever reached through `import()` — or not at all.
|
|
11
|
+
#
|
|
12
|
+
# Static imports only. A dynamic `import()` is the app saying "later", and
|
|
13
|
+
# preloading its target undoes the deferral the app asked for. That makes this
|
|
14
|
+
# a walk of what the browser fetches while linking the entry point, which is
|
|
15
|
+
# exactly what a modulepreload link is for.
|
|
16
|
+
#
|
|
17
|
+
# Nothing here reaches the network. The files are the ones the asset pipeline
|
|
18
|
+
# already serves, read once per Map cache generation and dropped with the rest
|
|
19
|
+
# of the cache when the sweeper sees a .js change.
|
|
20
|
+
class Importmap::Graph
|
|
21
|
+
# A path the browser fetches itself rather than one the asset pipeline
|
|
22
|
+
# serves — `https://…`, and the protocol-relative `//…` a hand-written pin
|
|
23
|
+
# can hold. Importmap::Packager::REMOTE_URL_REGEXP says the same thing, but
|
|
24
|
+
# requiring the CLI onto the request path to borrow it would be a poor trade.
|
|
25
|
+
REMOTE_PATH_REGEXP = %r{\A(?:[a-zA-Z][a-zA-Z0-9+\-.]*:)?//}.freeze # :nodoc:
|
|
26
|
+
|
|
27
|
+
def initialize(map, roots: [])
|
|
28
|
+
@map = map
|
|
29
|
+
@roots = Array(roots).map { |root| Pathname.new(root) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The keys reachable from +entry_points+ through static imports, the entry
|
|
33
|
+
# points themselves included. An entry point the map doesn't define, a pin
|
|
34
|
+
# whose file isn't on any asset path and a remote pin are all leaves: they
|
|
35
|
+
# contribute themselves and nothing further.
|
|
36
|
+
def reachable_from(entry_points)
|
|
37
|
+
queue = Array(entry_points).select { |key| entries.key?(key) }
|
|
38
|
+
reached = Set.new(queue)
|
|
39
|
+
|
|
40
|
+
while (key = queue.shift)
|
|
41
|
+
edges_from(key).each { |target| queue << target if reached.add?(target) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
reached
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
def entries
|
|
49
|
+
@entries ||= @map.each_expanded_package.to_h
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The key a path belongs to, for resolving a relative import back into the
|
|
53
|
+
# map. Two keys can name one file; the first one drawn wins, and since both
|
|
54
|
+
# resolve to the same asset path the preload set can't tell them apart
|
|
55
|
+
# anyway.
|
|
56
|
+
def keys_by_path
|
|
57
|
+
@keys_by_path ||= entries.each_with_object({}) { |(key, mapping), keys| keys[mapping.path] ||= key }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def edges_from(key)
|
|
61
|
+
@edges ||= {}
|
|
62
|
+
@edges[key] ||= compute_edges(entries[key])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def compute_edges(mapping)
|
|
66
|
+
file = file_for(mapping.path)
|
|
67
|
+
return [] unless file
|
|
68
|
+
|
|
69
|
+
Importmap::ImportScanner.new(source_of(file)).imports.filter_map { |import|
|
|
70
|
+
key_for(import.specifier, mapping.path) if import.kind == :static
|
|
71
|
+
}.uniq
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def file_for(path)
|
|
75
|
+
return if path.match?(REMOTE_PATH_REGEXP)
|
|
76
|
+
|
|
77
|
+
@roots.lazy.map { |root| root.join(path) }.find(&:file?)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Read as bytes and scrubbed, like Importmap::Doctor: a vendored file this
|
|
81
|
+
# gem didn't write can hold a sequence no UTF-8 regexp will match without
|
|
82
|
+
# raising, and one such file must not take a page's preloads with it.
|
|
83
|
+
def source_of(file)
|
|
84
|
+
File.binread(file).force_encoding(Encoding::UTF_8).scrub
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A relative specifier is resolved against the importing key's own path and
|
|
88
|
+
# matched back to the key holding that path — the browser resolves it
|
|
89
|
+
# against the importing module's URL, and the map's paths are what those
|
|
90
|
+
# URLs are built from. Anything else names a key outright, a path a key
|
|
91
|
+
# maps to, or a subpath of a package pinned as one file.
|
|
92
|
+
def key_for(specifier, importer_path)
|
|
93
|
+
return if specifier.empty?
|
|
94
|
+
|
|
95
|
+
if relative?(specifier)
|
|
96
|
+
keys_by_path[resolved_path(specifier, importer_path)]
|
|
97
|
+
else
|
|
98
|
+
exact_key(specifier) || enclosing_key(specifier)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def relative?(specifier)
|
|
103
|
+
specifier.start_with?("./", "../")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def resolved_path(specifier, importer_path)
|
|
107
|
+
Pathname.new(importer_path).dirname.join(specifier.sub(/[?#].*\z/, "")).cleanpath.to_s
|
|
108
|
+
rescue ArgumentError
|
|
109
|
+
nil
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def exact_key(specifier)
|
|
113
|
+
entries.key?(specifier) ? specifier : keys_by_path[specifier]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# `pin "foo/", to: "foo/"` covers everything under it, and a subpath of a
|
|
117
|
+
# package pinned as one file — which the browser can't resolve, and
|
|
118
|
+
# Importmap::Doctor reports — is counted as reaching that package, because
|
|
119
|
+
# preloading one file too many costs a request and preloading one too few
|
|
120
|
+
# costs the waterfall this whole mechanism exists to avoid.
|
|
121
|
+
def enclosing_key(specifier)
|
|
122
|
+
entries.keys.select { |key|
|
|
123
|
+
key.end_with?("/") ? specifier.start_with?(key) : specifier.start_with?("#{key}/")
|
|
124
|
+
}.max_by(&:length)
|
|
125
|
+
end
|
|
126
|
+
end
|