importmap-plus 1.0.0 → 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 +420 -0
- data/README.md +44 -430
- data/app/helpers/importmap/importmap_tags_helper.rb +3 -0
- data/lib/importmap/batch_resolver.rb +133 -0
- data/lib/importmap/commands.rb +546 -43
- 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 +37 -14
- data/lib/importmap/package_graph.rb +299 -0
- data/lib/importmap/packager.rb +432 -117
- data/lib/importmap/provider_chain.rb +89 -0
- data/lib/importmap/vendored_graph.rb +214 -0
- data/lib/importmap/version.rb +1 -1
- metadata +18 -6
data/lib/importmap/packager.rb
CHANGED
|
@@ -2,15 +2,28 @@ require "net/http"
|
|
|
2
2
|
require "uri"
|
|
3
3
|
require "json"
|
|
4
4
|
require "importmap/minifier"
|
|
5
|
+
require "importmap/module_inspector"
|
|
6
|
+
require "importmap/package_graph"
|
|
7
|
+
require "importmap/vendored_graph"
|
|
5
8
|
require "importmap/http_retries"
|
|
9
|
+
require "importmap/integrity"
|
|
10
|
+
require "importmap/esm_run"
|
|
6
11
|
|
|
7
12
|
class Importmap::Packager
|
|
8
13
|
include Importmap::HttpRetries
|
|
9
14
|
|
|
10
15
|
PIN_REGEX = /#{Importmap::Map::PIN_REGEX}(.*)/.freeze # :nodoc:
|
|
11
|
-
|
|
16
|
+
# The bracketed form matches an empty array too: `preload: []` is a pin an
|
|
17
|
+
# app wrote, and a rewrite that dropped it would start preloading the package
|
|
18
|
+
# on every page.
|
|
19
|
+
PRELOAD_OPTION_REGEXP = /preload:\s*(\[[^\]]*\]|true|false|["'][^"']*["'])/.freeze # :nodoc:
|
|
12
20
|
TO_OPTION_REGEXP = /to:\s*["']([^"']*)["']/.freeze # :nodoc:
|
|
21
|
+
# Only the booleans: a hash string is tied to the file it was computed for,
|
|
22
|
+
# so a rewrite that changes the URL has to drop it.
|
|
23
|
+
INTEGRITY_OPTION_REGEXP = /integrity:\s*(true|false)\b/.freeze # :nodoc:
|
|
24
|
+
INTEGRITY_HASH_REGEXP = /integrity:\s*["'][^"']+["']/.freeze # :nodoc:
|
|
13
25
|
REMOTE_URL_REGEXP = %r{\Ahttps?://}.freeze # :nodoc:
|
|
26
|
+
IDENTITY_ENCODING = { "Accept-Encoding" => "identity" }.freeze # :nodoc:
|
|
14
27
|
|
|
15
28
|
PROVIDER_HOSTS = {
|
|
16
29
|
"ga.jspm.io" => "jspm.io",
|
|
@@ -20,20 +33,6 @@ class Importmap::Packager
|
|
|
20
33
|
"esm.sh" => "esm.sh"
|
|
21
34
|
}.freeze # :nodoc:
|
|
22
35
|
|
|
23
|
-
# jsDelivr's bundling endpoint (https://www.jsdelivr.com/esm): one minified
|
|
24
|
-
# ESM file per package, with its dependencies referenced as /npm/dep@ver/+esm.
|
|
25
|
-
ESM_RUN_PROVIDER = "esm.run".freeze # :nodoc:
|
|
26
|
-
ESM_RUN_CDN = "https://cdn.jsdelivr.net/npm/".freeze # :nodoc:
|
|
27
|
-
ESM_RUN_URL_REGEXP = %r{\Ahttps://cdn\.jsdelivr\.net/npm/.+/\+esm\z}.freeze # :nodoc:
|
|
28
|
-
# An esm.run bundle's own imports: `from"/npm/dep@1.2.3/+esm"`, `import"…"`,
|
|
29
|
-
# `import("…")`, `export … from"…"`. Anchored on the keyword so an ordinary
|
|
30
|
-
# string that happens to look like a bundle URL is left alone. What it does
|
|
31
|
-
# not do is parse JavaScript, so the same text inside a string or a comment
|
|
32
|
-
# would still be rewritten — a jsDelivr bundle is esbuild output whose only
|
|
33
|
-
# surviving comment is the banner, and a root-relative /npm/ URL is
|
|
34
|
-
# meaningless anywhere but in one of its own imports.
|
|
35
|
-
ESM_RUN_IMPORT_REGEXP =
|
|
36
|
-
%r{((?:\bfrom|\bimport)\s*\(?\s*)(["'])/npm/((?:@[^/"'@]+/)?[^/"'@]+)@([^/"']+)((?:/[^"']*?)?)/\+esm\2}.freeze # :nodoc:
|
|
37
36
|
# name[@version][/subpath] — a leading "@" distinguishes a scoped name
|
|
38
37
|
# (@scope/pkg) from an unscoped name with a subpath (apexcharts/core).
|
|
39
38
|
PACKAGE_SPEC_REGEXP = %r{\A(@[^@/]+/[^@/]+|[^@/]+)(?:@([^/]+))?(/.+)?\z}.freeze # :nodoc:
|
|
@@ -41,18 +40,64 @@ class Importmap::Packager
|
|
|
41
40
|
# pin "luxon" # @3.7.2
|
|
42
41
|
# pin "luxon" # @3.7.2 (esm.run, minified)
|
|
43
42
|
PIN_PROVENANCE_REGEXP = /#\s*@([^\s(]+)(?:\s+\(([^)]*)\))?/.freeze # :nodoc:
|
|
43
|
+
# A lock is one more detail in that list, always last:
|
|
44
|
+
# pin "luxon" # @3.7.2 (esm.run, minified, locked)
|
|
45
|
+
# "locked: <range>" is reserved for range locks, so it is read as a lock
|
|
46
|
+
# today rather than mistaken for a provider name.
|
|
47
|
+
LOCK_DETAIL = "locked".freeze # :nodoc:
|
|
48
|
+
LOCK_DETAIL_REGEXP = /\Alocked(?::\s*(.+))?\z/.freeze # :nodoc:
|
|
49
|
+
# Why a package was left pinned to its CDN URL instead of vendored, and the
|
|
50
|
+
# mark that says a vendored file was kept despite that check:
|
|
51
|
+
# pin "@popperjs/core", to: "https://…" # @2.11.8 (remote: relative imports)
|
|
52
|
+
# pin "@popperjs/core", to: "@popperjs--core.js" # @2.11.8 (vendored)
|
|
53
|
+
# Both sit in the same slot — a pin is one or the other, never both — and
|
|
54
|
+
# both have to be excluded when the provider is read, or "vendored" and
|
|
55
|
+
# "remote: workers" are taken for CDN names.
|
|
56
|
+
REMOTE_DETAIL = "remote".freeze # :nodoc:
|
|
57
|
+
REMOTE_DETAIL_REGEXP = /\Aremote(?::\s*(.+))?\z/.freeze # :nodoc:
|
|
58
|
+
VENDORED_DETAIL = "vendored".freeze # :nodoc:
|
|
44
59
|
DEFAULT_PROVIDER = "jspm.io".freeze # :nodoc:
|
|
45
60
|
|
|
46
61
|
Error = Class.new(StandardError)
|
|
47
62
|
HTTPError = Class.new(Error)
|
|
48
|
-
ServiceError =
|
|
63
|
+
ServiceError = Class.new(Error)
|
|
64
|
+
|
|
65
|
+
# A download that can't be served as the files an import map entry points at.
|
|
66
|
+
# Raised before anything is written, so the vendored file an app already has
|
|
67
|
+
# survives; #reasons lists what Importmap::ModuleInspector found, less the
|
|
68
|
+
# relative imports a file graph answers for. The pin is kept remote, and
|
|
69
|
+
# #integrity is the hash of the bytes as the CDN served them — before any
|
|
70
|
+
# rewriting — so that pin carries it without a second fetch.
|
|
71
|
+
class Unvendorable < Error
|
|
72
|
+
attr_reader :reasons, :integrity
|
|
73
|
+
|
|
74
|
+
def initialize(reasons, integrity: nil)
|
|
75
|
+
@reasons = Array(reasons)
|
|
76
|
+
@integrity = integrity
|
|
77
|
+
super("can't be vendored as a single file (#{@reasons.join(", ")})")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# A download that isn't an ES module — a CommonJS or UMD bundle, which every
|
|
82
|
+
# CDN that serves a package's own dist file will hand back for a package that
|
|
83
|
+
# publishes one. Loaded through an import map it runs and exports nothing, so
|
|
84
|
+
# the app's `import x from "pkg"` fails to link in the browser and takes the
|
|
85
|
+
# importing module down with it. Raised before anything is written, like
|
|
86
|
+
# Unvendorable, and kept remote the same way, with the same #reasons and
|
|
87
|
+
# #integrity so the caller can treat the two alike.
|
|
88
|
+
class NotAnEsModule < Error
|
|
89
|
+
attr_reader :reasons, :integrity
|
|
90
|
+
|
|
91
|
+
def initialize(integrity: nil)
|
|
92
|
+
@reasons = [ "not an ES module" ]
|
|
93
|
+
@integrity = integrity
|
|
94
|
+
super("isn't an ES module")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
49
97
|
|
|
50
98
|
singleton_class.attr_accessor :endpoint
|
|
51
99
|
self.endpoint = URI("https://api.jspm.io/generate")
|
|
52
100
|
|
|
53
|
-
singleton_class.attr_accessor :esm_run_resolver
|
|
54
|
-
self.esm_run_resolver = URI("https://data.jsdelivr.com/v1/packages/npm/")
|
|
55
|
-
|
|
56
101
|
# CDNs reset connections and rate-limit bursts. Each request is tried this
|
|
57
102
|
# many times, pausing retry_wait × attempt between tries, before it fails.
|
|
58
103
|
# Shared with Importmap::Npm, which talks to the registry the same way.
|
|
@@ -66,6 +111,13 @@ class Importmap::Packager
|
|
|
66
111
|
def retry_wait=(value)
|
|
67
112
|
Importmap::HttpRetries.wait = value
|
|
68
113
|
end
|
|
114
|
+
|
|
115
|
+
# Where --from esm.run resolves versions. Documented on this class since
|
|
116
|
+
# before Importmap::EsmRun held it, so it keeps answering here.
|
|
117
|
+
def esm_run_resolver = Importmap::EsmRun.resolver
|
|
118
|
+
def esm_run_resolver=(value)
|
|
119
|
+
Importmap::EsmRun.resolver = value
|
|
120
|
+
end
|
|
69
121
|
end
|
|
70
122
|
|
|
71
123
|
# Anything responding to #call(source) => String. Defaults to the first of
|
|
@@ -78,13 +130,27 @@ class Importmap::Packager
|
|
|
78
130
|
|
|
79
131
|
attr_reader :vendor_path
|
|
80
132
|
|
|
133
|
+
# What the CDN said about the most recent #import that came back empty, or
|
|
134
|
+
# nil. jspm answers 401 with its generator's reason in the body — "No
|
|
135
|
+
# './dist/cytoscape.umd.js' exports subpath defined" — and #import still
|
|
136
|
+
# answers nil, because one spec a CDN can't serve must not end a whole run.
|
|
137
|
+
# The reason is worth repeating to whoever asked, and to the next CDN's turn.
|
|
138
|
+
attr_reader :last_import_error
|
|
139
|
+
|
|
140
|
+
# The file graph the most recent #download vendored beside its entry, or nil
|
|
141
|
+
# when that download was one file. A reader rather than a second return
|
|
142
|
+
# value: #download's is the esm.run dependency list callers destructure.
|
|
143
|
+
attr_reader :last_graph
|
|
144
|
+
|
|
81
145
|
def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript")
|
|
82
146
|
@importmap_path = Pathname.new(importmap_path)
|
|
83
147
|
@vendor_path = Pathname.new(vendor_path)
|
|
84
148
|
end
|
|
85
149
|
|
|
86
150
|
def import(*packages, env: "production", from: "jspm")
|
|
87
|
-
|
|
151
|
+
@last_import_error = nil
|
|
152
|
+
|
|
153
|
+
return Importmap::EsmRun.new(self).imports(packages) if esm_run?(from)
|
|
88
154
|
|
|
89
155
|
response = post_json({
|
|
90
156
|
"install" => Array(packages),
|
|
@@ -97,70 +163,191 @@ class Importmap::Packager
|
|
|
97
163
|
when "200"
|
|
98
164
|
extract_parsed_response(response)
|
|
99
165
|
when "404", "401"
|
|
166
|
+
@last_import_error = parse_service_error(response)
|
|
100
167
|
nil
|
|
101
168
|
else
|
|
102
169
|
handle_failure_response(response)
|
|
103
170
|
end
|
|
104
171
|
end
|
|
105
172
|
|
|
106
|
-
|
|
173
|
+
# A remote pin has no version comment unless it is locked or was kept remote
|
|
174
|
+
# because its file can't stand alone; then the version in its URL is written
|
|
175
|
+
# out so the lock and the reason have something to hang off:
|
|
176
|
+
#
|
|
177
|
+
# pin "md5", to: "https://cdn.jsdelivr.net/npm/md5@2.2.0/md5.js" # @2.2.0 (locked)
|
|
178
|
+
# pin "@popperjs/core", to: "https://ga.jspm.io/…" # @2.11.8 (remote: relative imports)
|
|
179
|
+
#
|
|
180
|
+
def pin_for(package, url = nil, preloads: nil, integrity: nil, locked: false, remote: nil)
|
|
107
181
|
to = url ? %(, to: "#{url}") : ""
|
|
108
182
|
preload_param = preload(preloads)
|
|
183
|
+
integrity_param = integrity.nil? ? "" : %(, integrity: #{integrity.inspect})
|
|
184
|
+
version = extract_package_version_from(url.to_s) if locked || remote
|
|
109
185
|
|
|
110
|
-
|
|
186
|
+
%(pin "#{package}") + to + preload_param + integrity_param +
|
|
187
|
+
(version ? provenance_comment(version, remote: remote, locked: locked) : "")
|
|
111
188
|
end
|
|
112
189
|
|
|
113
190
|
# The pin line for a vendored download. The version comment also records
|
|
114
|
-
# the CDN when it isn't jspm
|
|
115
|
-
# update or pristine can do the same again:
|
|
191
|
+
# the CDN when it isn't jspm, whether the file was minified and whether the
|
|
192
|
+
# version is locked, so a later update or pristine can do the same again:
|
|
116
193
|
#
|
|
117
194
|
# pin "luxon" # @3.7.2
|
|
118
|
-
# pin "luxon" # @3.7.2 (esm.run, minified)
|
|
195
|
+
# pin "luxon" # @3.7.2 (esm.run, minified, locked)
|
|
119
196
|
#
|
|
120
|
-
def vendored_pin_for(package, url, preloads = nil, minify: false)
|
|
197
|
+
def vendored_pin_for(package, url, preloads = nil, minify: false, integrity: nil, locked: false, vendored: false)
|
|
121
198
|
filename = package_filename(package)
|
|
122
199
|
version = extract_package_version_from(url)
|
|
123
200
|
to = "#{package}.js" != filename ? filename : nil
|
|
124
201
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
202
|
+
pin_for(package, to, preloads: preloads, integrity: integrity) +
|
|
203
|
+
provenance_comment(version, provider: provider_for_url(url), minified: minify, vendored: vendored, locked: locked)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# The directory +package+'s sibling files are vendored into and the line that
|
|
207
|
+
# maps them; the paths under vendor/javascript are still built only here.
|
|
208
|
+
def vendored_graph(package)
|
|
209
|
+
Importmap::VendoredGraph.new(graph_path(package), importmap_path: @importmap_path)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The pin_all_from line for +package+'s graph, carrying the entry's own
|
|
213
|
+
# preload. Its keys go under the package the CDN *URL* names — jspm resolves
|
|
214
|
+
# Node's "buffer" to a file in @jspm/core.
|
|
215
|
+
def graph_pin_for(package, url, preloads = nil)
|
|
216
|
+
name, version = Importmap::PackageGraph.package_and_version_for(url)
|
|
217
|
+
|
|
218
|
+
vendored_graph(package).line_for(under: name, options: preload(preloads),
|
|
219
|
+
version: version || extract_package_version_from(url))
|
|
220
|
+
end
|
|
128
221
|
|
|
129
|
-
|
|
222
|
+
def remove_graph(package)
|
|
223
|
+
reload! if vendored_graph(package).remove
|
|
130
224
|
end
|
|
131
225
|
|
|
132
|
-
# What the pin's version comment says a
|
|
133
|
-
# { version:, provider:, minified: }, or nil for
|
|
226
|
+
# What the pin's version comment says a package was built with:
|
|
227
|
+
# { version:, provider:, minified:, vendored:, remote:, locked: }, or nil for
|
|
228
|
+
# a pin without one.
|
|
134
229
|
def pin_provenance(package)
|
|
230
|
+
provenance_of(pin_line_for(package))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# The line that pins +package+, without its newline, or nil.
|
|
234
|
+
def pin_line_for(package)
|
|
135
235
|
return unless @importmap_path.exist?
|
|
136
236
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
237
|
+
importmap.lines.find { |candidate| candidate.match?(Importmap::Map.pin_line_regexp_for(package)) }&.chomp
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The version a pin declares, in its provenance comment or in the CDN URL it
|
|
241
|
+
# points at, or nil when it names none — as one of the app's own files
|
|
242
|
+
# doesn't. Only a pin with a version can be told to be outdated.
|
|
243
|
+
def pin_version(package)
|
|
244
|
+
provenance = pin_provenance(package)
|
|
245
|
+
return provenance[:version] if provenance
|
|
246
|
+
|
|
247
|
+
to = (extract_existing_pin_options(package)[package] || {})[:to]
|
|
248
|
+
extract_package_version_from(to.to_s)&.delete_prefix("@")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def locked?(package)
|
|
252
|
+
pin_provenance(package)&.dig(:locked) || false
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Whether the pin carries a computed hash. #extract_existing_pin_options
|
|
256
|
+
# leaves a hash string out on purpose — it belongs to one file — so this is
|
|
257
|
+
# how a caller about to drop one says so.
|
|
258
|
+
def integrity_hash?(package)
|
|
259
|
+
pin_line_for(package).to_s.match?(INTEGRITY_HASH_REGEXP)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# The import-map keys of every pin, in file order.
|
|
263
|
+
def pinned_packages
|
|
264
|
+
return [] unless @importmap_path.exist?
|
|
265
|
+
|
|
266
|
+
importmap.lines.filter_map { |line| line.strip[PIN_REGEX, 1] }
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# The import-map keys of every locked pin, in file order.
|
|
270
|
+
def locked_pins
|
|
271
|
+
return [] unless @importmap_path.exist?
|
|
272
|
+
|
|
273
|
+
importmap.lines.filter_map do |line|
|
|
274
|
+
name = line.strip[PIN_REGEX, 1]
|
|
275
|
+
name if name && provenance_of(line)&.dig(:locked)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The pin line with a lock added — to the version comment it has, or as a
|
|
280
|
+
# new comment carrying the version from its URL. Nothing else on the line is
|
|
281
|
+
# touched. Nil when the pin has no version to lock at, or isn't there.
|
|
282
|
+
def locked_pin_line(package)
|
|
283
|
+
line = pin_line_for(package)
|
|
284
|
+
return unless line
|
|
285
|
+
|
|
286
|
+
if line.match?(PIN_PROVENANCE_REGEXP)
|
|
287
|
+
rewrite_provenance(line) { |details| without_lock(details) << LOCK_DETAIL }
|
|
288
|
+
elsif (to = (extract_existing_pin_options(package)[package] || {})[:to].to_s).match?(REMOTE_URL_REGEXP) &&
|
|
289
|
+
(version = extract_package_version_from(to))
|
|
290
|
+
line + provenance_comment(version, locked: true)
|
|
291
|
+
end
|
|
292
|
+
end
|
|
140
293
|
|
|
141
|
-
|
|
142
|
-
|
|
294
|
+
# The pin line with its lock removed; an empty detail list drops its parens.
|
|
295
|
+
def unlocked_pin_line(package)
|
|
296
|
+
line = pin_line_for(package)
|
|
143
297
|
|
|
144
|
-
{
|
|
298
|
+
rewrite_provenance(line) { |details| without_lock(details) } if line
|
|
145
299
|
end
|
|
146
300
|
|
|
147
301
|
def packaged?(package)
|
|
148
302
|
importmap.match(Importmap::Map.pin_line_regexp_for(package))
|
|
149
303
|
end
|
|
150
304
|
|
|
151
|
-
# Downloads +url+ into vendor/javascript
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
# absolute /npm/... imports rewritten
|
|
156
|
-
|
|
305
|
+
# Downloads +url+ into vendor/javascript, with the sibling files it imports
|
|
306
|
+
# when it has any. With +minify: true+ the source is run through .minifier
|
|
307
|
+
# first and the file header records it, so later updates keep minifying.
|
|
308
|
+
# Returns the dependencies an esm.run bundle imports as [package, url] pairs
|
|
309
|
+
# (empty for every other provider), its absolute /npm/... imports rewritten
|
|
310
|
+
# to bare specifiers on the way in. Raises Unvendorable unless +force+ when
|
|
311
|
+
# the download needs more than a file graph can give it. +graph: false+
|
|
312
|
+
# brings the entry down alone and drops the directory it had (--vendor); the
|
|
313
|
+
# caller decides, because only it knows whether the pin already maps one.
|
|
314
|
+
def download(package, url, minify: false, force: false, graph: true)
|
|
315
|
+
@last_graph = nil
|
|
316
|
+
|
|
157
317
|
ensure_vendor_directory_exists
|
|
158
|
-
|
|
159
|
-
|
|
318
|
+
download_package_file(package, url, minify: minify, force: force, graph: graph)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# The body at +url+ — the one GET this class makes, whether the file is
|
|
322
|
+
# going to be vendored or only hashed for a remote pin. Writes nothing and
|
|
323
|
+
# inspects nothing. Like #post_json, a failure the retry doesn't recognise
|
|
324
|
+
# still comes out as this class's HTTPError rather than a backtrace.
|
|
325
|
+
def fetch_remote(url, allow_missing: false, description: "downloading #{url}")
|
|
326
|
+
response = get_response(url, description: description)
|
|
327
|
+
# jspm answers some files brotli whatever the request advertises, and
|
|
328
|
+
# Net::HTTP decodes gzip and deflate only. Not asked up front: supplying an
|
|
329
|
+
# Accept-Encoding at all stops it decoding the gzip it does understand.
|
|
330
|
+
response = get_response(url, IDENTITY_ENCODING, description: description) if response.code == "200" && encoded?(response.body)
|
|
331
|
+
|
|
332
|
+
if response.code == "200"
|
|
333
|
+
# Still unreadable asked plain: say so, rather than let ModuleInspector raise.
|
|
334
|
+
raise HTTPError, "Can't read #{url}: the CDN sent an encoding this gem can't decode" if encoded?(response.body)
|
|
335
|
+
|
|
336
|
+
response.body
|
|
337
|
+
elsif response.code == "404" && allow_missing
|
|
338
|
+
nil
|
|
339
|
+
else
|
|
340
|
+
handle_failure_response(response)
|
|
341
|
+
end
|
|
342
|
+
rescue Error
|
|
343
|
+
raise
|
|
344
|
+
rescue => error
|
|
345
|
+
raise HTTPError, "Unexpected transport error downloading #{url} (#{error.class}: #{error.message})"
|
|
160
346
|
end
|
|
161
347
|
|
|
162
348
|
def remove(package)
|
|
163
349
|
remove_existing_package_file(package)
|
|
350
|
+
remove_graph(package)
|
|
164
351
|
remove_package_from_importmap(package)
|
|
165
352
|
end
|
|
166
353
|
|
|
@@ -192,13 +379,27 @@ class Importmap::Packager
|
|
|
192
379
|
{ provider: provider == DEFAULT_PROVIDER ? nil : provider, minified: minify ? true : false }
|
|
193
380
|
end
|
|
194
381
|
|
|
382
|
+
# Why a pin was kept remote, as its comment records it: the reason string,
|
|
383
|
+
# true for a bare "(remote)", or nil. Passed back to #pin_for on a rewrite so
|
|
384
|
+
# update and pristine don't drop it.
|
|
385
|
+
def remote_reason(package)
|
|
386
|
+
pin_provenance(package)&.dig(:remote)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Whether the pin says it was vendored on purpose — `pin --vendor` overriding
|
|
390
|
+
# the single-file check — so a later update vendors it again instead of
|
|
391
|
+
# converting it back to a remote pin.
|
|
392
|
+
def vendored?(package)
|
|
393
|
+
pin_provenance(package)&.dig(:vendored) || false
|
|
394
|
+
end
|
|
395
|
+
|
|
195
396
|
def remote_pin?(package)
|
|
196
397
|
options = extract_existing_pin_options(package)[package] || {}
|
|
197
398
|
options[:to].to_s.match?(REMOTE_URL_REGEXP)
|
|
198
399
|
end
|
|
199
400
|
|
|
200
401
|
def provider_for_url(url)
|
|
201
|
-
return
|
|
402
|
+
return Importmap::EsmRun::PROVIDER if Importmap::EsmRun.url?(url)
|
|
202
403
|
|
|
203
404
|
PROVIDER_HOSTS[URI(url.to_s).host]
|
|
204
405
|
rescue URI::InvalidURIError
|
|
@@ -206,7 +407,7 @@ class Importmap::Packager
|
|
|
206
407
|
end
|
|
207
408
|
|
|
208
409
|
def esm_run?(provider)
|
|
209
|
-
provider
|
|
410
|
+
Importmap::EsmRun.provider?(provider)
|
|
210
411
|
end
|
|
211
412
|
|
|
212
413
|
# The import-map key a package spec pins: "apexcharts@7.1.0/core" pins
|
|
@@ -216,6 +417,29 @@ class Importmap::Packager
|
|
|
216
417
|
name ? "#{name}#{subpath}" : spec.to_s
|
|
217
418
|
end
|
|
218
419
|
|
|
420
|
+
# The package a spec or key belongs to, as the registry knows it:
|
|
421
|
+
# "photoswipe/lightbox" and "@hotwired/stimulus@3" are pins of "photoswipe"
|
|
422
|
+
# and "@hotwired/stimulus".
|
|
423
|
+
def package_name_for(spec)
|
|
424
|
+
spec.to_s.match(PACKAGE_SPEC_REGEXP)&.captures&.first || spec.to_s
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# The spec that asks a CDN for +key+ at +version+. A version belongs on the
|
|
428
|
+
# package name, ahead of the subpath: "photoswipe/lightbox" at "@5.4.4" is
|
|
429
|
+
# "photoswipe@5.4.4/lightbox", where "photoswipe/lightbox@5.4.4" is a path
|
|
430
|
+
# no CDN has.
|
|
431
|
+
def package_spec_for(key, version)
|
|
432
|
+
name, _requested, subpath = key.to_s.match(PACKAGE_SPEC_REGEXP)&.captures
|
|
433
|
+
|
|
434
|
+
name ? "#{name}#{version}#{subpath}" : "#{key}#{version}"
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Where +package+'s download is vendored: with #graph_path, the only place a
|
|
438
|
+
# path under vendor/javascript is built from a package name.
|
|
439
|
+
def vendored_package_path(package)
|
|
440
|
+
@vendor_path.join(package_filename(package))
|
|
441
|
+
end
|
|
442
|
+
|
|
219
443
|
def remove_existing_package_file(package)
|
|
220
444
|
FileUtils.rm_rf vendored_package_path(package)
|
|
221
445
|
end
|
|
@@ -225,6 +449,61 @@ class Importmap::Packager
|
|
|
225
449
|
end
|
|
226
450
|
|
|
227
451
|
private
|
|
452
|
+
def provenance_of(line)
|
|
453
|
+
match = line&.match(PIN_PROVENANCE_REGEXP)
|
|
454
|
+
return unless match
|
|
455
|
+
|
|
456
|
+
details = match[2].to_s.split(",").map(&:strip)
|
|
457
|
+
minified = details.delete("minified") ? true : false
|
|
458
|
+
locked = without_lock(details).size != details.size
|
|
459
|
+
|
|
460
|
+
{ version: match[1], provider: without_named_details(details).first, minified: minified,
|
|
461
|
+
vendored: details.include?(VENDORED_DETAIL), remote: remote_detail_of(details), locked: locked }
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
def without_lock(details)
|
|
465
|
+
details.reject { |detail| detail.match?(LOCK_DETAIL_REGEXP) }
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# The provider is read as the first detail left over, so every named detail
|
|
469
|
+
# has to come out of the list first or it is taken for a CDN name.
|
|
470
|
+
def without_named_details(details)
|
|
471
|
+
without_lock(details).reject { |detail| detail == VENDORED_DETAIL || detail.match?(REMOTE_DETAIL_REGEXP) }
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# "remote: relative imports" carries its reason; a bare "remote" says only
|
|
475
|
+
# that the pin was kept remote, which is still worth keeping on a rewrite.
|
|
476
|
+
def remote_detail_of(details)
|
|
477
|
+
match = details.filter_map { |detail| detail.match(REMOTE_DETAIL_REGEXP) }.first
|
|
478
|
+
return unless match
|
|
479
|
+
|
|
480
|
+
match[1] || true
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# " # @3.7.2 (esm.run, minified, locked)" — the details in their fixed
|
|
484
|
+
# order, the parens only when there is something to say.
|
|
485
|
+
def provenance_comment(version, provider: nil, minified: false, vendored: false, remote: nil, locked: false)
|
|
486
|
+
details = []
|
|
487
|
+
details << provider if provider && provider != DEFAULT_PROVIDER
|
|
488
|
+
details << "minified" if minified
|
|
489
|
+
details << VENDORED_DETAIL if vendored
|
|
490
|
+
details << (remote == true ? REMOTE_DETAIL : "#{REMOTE_DETAIL}: #{remote}") if remote
|
|
491
|
+
details << LOCK_DETAIL if locked
|
|
492
|
+
|
|
493
|
+
%( # @#{version.to_s.delete_prefix("@")}) + (details.any? ? %( (#{details.join(", ")})) : "")
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
# Rewrites only the version comment of +line+, handing the block the
|
|
497
|
+
# current details and writing back what it returns.
|
|
498
|
+
def rewrite_provenance(line)
|
|
499
|
+
line.sub(PIN_PROVENANCE_REGEXP) do
|
|
500
|
+
version = $1
|
|
501
|
+
details = yield($2.to_s.split(",").map(&:strip))
|
|
502
|
+
|
|
503
|
+
"# @#{version}" + (details.any? ? " (#{details.join(", ")})" : "")
|
|
504
|
+
end
|
|
505
|
+
end
|
|
506
|
+
|
|
228
507
|
def build_package_options_lookup(lines)
|
|
229
508
|
lines.each_with_object({}) do |line, package_options|
|
|
230
509
|
match = line.strip.match(PIN_REGEX)
|
|
@@ -243,6 +522,10 @@ class Importmap::Packager
|
|
|
243
522
|
options[:to] = to_match[1]
|
|
244
523
|
end
|
|
245
524
|
|
|
525
|
+
if (integrity_match = options_part.match(INTEGRITY_OPTION_REGEXP))
|
|
526
|
+
options[:integrity] = integrity_match[1] == "true"
|
|
527
|
+
end
|
|
528
|
+
|
|
246
529
|
package_options[package_name] = options if options.any?
|
|
247
530
|
end
|
|
248
531
|
end
|
|
@@ -255,16 +538,24 @@ class Importmap::Packager
|
|
|
255
538
|
when "false"
|
|
256
539
|
false
|
|
257
540
|
when /^\[.*\]$/
|
|
258
|
-
JSON
|
|
541
|
+
# config/importmap.rb is Ruby, not JSON, and a single-quoted pin is a
|
|
542
|
+
# supported shape here, so the entry points are scanned out of the
|
|
543
|
+
# literal rather than parsed. JSON.parse raised on every one of them.
|
|
544
|
+
value.scan(/["']([^"']*)["']/).flatten
|
|
259
545
|
else
|
|
260
546
|
value.gsub(/["']/, "")
|
|
261
547
|
end
|
|
262
548
|
end
|
|
263
549
|
|
|
264
550
|
def preload(preloads)
|
|
551
|
+
# nil is "the pin carries no preload:"; [] is the pin carrying one that
|
|
552
|
+
# names no entry point. Array() flattens both to [], so the difference
|
|
553
|
+
# has to be read before it.
|
|
554
|
+
return "" if preloads.nil?
|
|
555
|
+
|
|
265
556
|
case Array(preloads)
|
|
266
557
|
in []
|
|
267
|
-
|
|
558
|
+
%(, preload: [])
|
|
268
559
|
in ["true"] | [true]
|
|
269
560
|
%(, preload: true)
|
|
270
561
|
in ["false"] | [false]
|
|
@@ -313,6 +604,16 @@ class Importmap::Packager
|
|
|
313
604
|
nil
|
|
314
605
|
end
|
|
315
606
|
|
|
607
|
+
def get_response(url, headers = nil, description: "downloading #{url}")
|
|
608
|
+
with_retries(description) { Net::HTTP.get_response(URI(url), headers) }
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# An encoding Net::HTTP couldn't undo is the one thing that reaches here as
|
|
612
|
+
# bytes no JavaScript file has: every CDN serves source as UTF-8.
|
|
613
|
+
def encoded?(body)
|
|
614
|
+
!body.to_s.dup.force_encoding("UTF-8").valid_encoding?
|
|
615
|
+
end
|
|
616
|
+
|
|
316
617
|
def importmap
|
|
317
618
|
@importmap ||= File.read(@importmap_path)
|
|
318
619
|
end
|
|
@@ -329,100 +630,114 @@ class Importmap::Packager
|
|
|
329
630
|
File.open(@importmap_path, "w") do |file|
|
|
330
631
|
with_lines_removed.each { |line| file.write(line) }
|
|
331
632
|
end
|
|
633
|
+
|
|
634
|
+
# Whatever asked to remove the pin may go on to ask whether it is still
|
|
635
|
+
# there, and #importmap answers from a memo of the file as it was.
|
|
636
|
+
reload!
|
|
332
637
|
end
|
|
333
638
|
|
|
334
|
-
|
|
335
|
-
|
|
639
|
+
# The file an app already has is replaced only once the new one is known to
|
|
640
|
+
# be downloadable, minifiable and servable on its own; a refusal here, or a
|
|
641
|
+
# failure above it, leaves the working file in place. The inspection runs on
|
|
642
|
+
# the source as it will be written — after an esm.run bundle's imports have
|
|
643
|
+
# become bare specifiers, before minifying, which rewrites nothing that
|
|
644
|
+
# matters to it.
|
|
645
|
+
def download_package_file(package, url, minify: false, force: false, graph: true)
|
|
646
|
+
body = fetch_remote(url)
|
|
647
|
+
source = body.dup.force_encoding("UTF-8")
|
|
648
|
+
source, dependencies = Importmap::EsmRun.rewrite_imports(source) if Importmap::EsmRun.url?(url)
|
|
336
649
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
source, dependencies = rewrite_esm_run_imports(source) if url.match?(ESM_RUN_URL_REGEXP)
|
|
340
|
-
source = self.class.minifier.call(source) if minify
|
|
650
|
+
@last_graph = Importmap::PackageGraph.for_download(self, package, url, source, body) if graph
|
|
651
|
+
source = @last_graph.entry_source if @last_graph
|
|
341
652
|
|
|
342
|
-
|
|
653
|
+
# force is the app overriding the check — except when the pin says it has
|
|
654
|
+
# a graph and the CDN didn't give one: --from skypack on a graphed pin
|
|
655
|
+
# would write the entry unrewritten and drop the directory it resolves through.
|
|
656
|
+
ensure_servable(source, body) unless force && (@last_graph || !graph)
|
|
343
657
|
|
|
344
|
-
|
|
345
|
-
else
|
|
346
|
-
handle_failure_response(response)
|
|
347
|
-
end
|
|
348
|
-
end
|
|
658
|
+
source = self.class.minifier.call(source) if minify
|
|
349
659
|
|
|
350
|
-
|
|
351
|
-
File.open(vendored_package_path(package), "w+") do |vendored_package|
|
|
352
|
-
vendored_package.write "// #{package}#{extract_package_version_from(url)} downloaded from #{url}#{" (minified)" if minified}\n\n"
|
|
660
|
+
save_vendored_package(package, url, source, minified: minify, graph: @last_graph)
|
|
353
661
|
|
|
354
|
-
|
|
355
|
-
end
|
|
662
|
+
dependencies || []
|
|
356
663
|
end
|
|
357
664
|
|
|
358
|
-
#
|
|
359
|
-
#
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
665
|
+
# One inspection answers both questions; standing alone is asked first, as
|
|
666
|
+
# the answer an app can act on by keeping the pin remote. A refusal carries
|
|
667
|
+
# the hash of +body+ — the bytes as the CDN served them, not the rewritten
|
|
668
|
+
# +source+ — so the pin kept remote doesn't fetch the file again for it.
|
|
669
|
+
def ensure_servable(source, body)
|
|
670
|
+
inspection = Importmap::ModuleInspector.new(source)
|
|
671
|
+
return if inspection.vendorable? && inspection.es_module?
|
|
363
672
|
|
|
364
|
-
|
|
365
|
-
keyword, quote, name, version, subpath = $1, $2, $3, $4, $5.to_s
|
|
366
|
-
key = "#{name}#{subpath}"
|
|
367
|
-
dependencies[key] ||= "#{ESM_RUN_CDN}#{name}@#{version}#{subpath}/+esm"
|
|
368
|
-
versions[key] << version unless versions[key].include?(version)
|
|
369
|
-
"#{keyword}#{quote}#{name}#{subpath}#{quote}"
|
|
370
|
-
end
|
|
673
|
+
integrity = Importmap::Integrity.for(body)
|
|
371
674
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
versions.each do |key, seen|
|
|
376
|
-
next if seen.one?
|
|
675
|
+
raise Unvendorable.new(inspection.reasons, integrity: integrity) unless inspection.vendorable?
|
|
676
|
+
raise NotAnEsModule.new(integrity: integrity)
|
|
677
|
+
end
|
|
377
678
|
|
|
378
|
-
|
|
379
|
-
|
|
679
|
+
# Prepared beside its targets and moved over them last, so a write that
|
|
680
|
+
# fails partway leaves the files the app had rather than half the new ones;
|
|
681
|
+
# the partials carry the pid so two shells pinning one package can't write
|
|
682
|
+
# each other's. Entry and directory are one unit — each is broken beside
|
|
683
|
+
# the other's old version — so both are prepared before either is moved,
|
|
684
|
+
# and the entry's partial waits in an ensure (Ctrl-C through a 250-file
|
|
685
|
+
# minify is not a StandardError) for the directory to be written.
|
|
686
|
+
def save_vendored_package(package, url, source, minified: false, graph: nil)
|
|
687
|
+
vendored = vendored_graph(package)
|
|
688
|
+
entry_partial = write_entry_partial(package, url, source, minified: minified)
|
|
689
|
+
committed = false
|
|
690
|
+
|
|
691
|
+
begin
|
|
692
|
+
graph_partial = graph && vendored.write(graph) do |file_source|
|
|
693
|
+
remove_sourcemap_comment_from(minified ? self.class.minifier.call(file_source) : file_source)
|
|
694
|
+
end
|
|
380
695
|
|
|
381
|
-
|
|
696
|
+
vendored.commit(graph_partial)
|
|
697
|
+
commit_entry(package, entry_partial)
|
|
698
|
+
committed = true
|
|
699
|
+
ensure
|
|
700
|
+
FileUtils.rm_f entry_partial unless committed
|
|
701
|
+
FileUtils.rm_rf graph_partial if graph_partial && !committed
|
|
702
|
+
end
|
|
382
703
|
end
|
|
383
704
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
705
|
+
# Cleans up its own partial: a write that dies partway dies before the
|
|
706
|
+
# caller has the partial's name.
|
|
707
|
+
def write_entry_partial(package, url, source, minified: false)
|
|
708
|
+
partial = Pathname.new("#{vendored_package_path(package)}.#{Process.pid}.download")
|
|
709
|
+
written = false
|
|
388
710
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
711
|
+
begin
|
|
712
|
+
File.open(partial, "w+") do |vendored_package|
|
|
713
|
+
vendored_package.write "// #{package}#{extract_package_version_from(url)} downloaded from #{url}#{" (minified)" if minified}\n\n"
|
|
714
|
+
vendored_package.write remove_sourcemap_comment_from(source).force_encoding("UTF-8")
|
|
715
|
+
end
|
|
716
|
+
written = true
|
|
717
|
+
ensure
|
|
718
|
+
FileUtils.rm_f partial unless written
|
|
393
719
|
end
|
|
394
720
|
|
|
395
|
-
|
|
721
|
+
partial
|
|
396
722
|
end
|
|
397
723
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
response = with_retries("resolving #{uri}") { Net::HTTP.get_response(uri) }
|
|
404
|
-
|
|
405
|
-
case response.code
|
|
406
|
-
when "200"
|
|
407
|
-
JSON.parse(response.body)["version"]
|
|
408
|
-
when "404"
|
|
409
|
-
nil
|
|
410
|
-
else
|
|
411
|
-
handle_failure_response(response)
|
|
412
|
-
end
|
|
413
|
-
rescue JSON::ParserError
|
|
414
|
-
raise HTTPError, "Unexpected response from #{uri}"
|
|
724
|
+
# Rename is atomic over a file; a directory in the way is cleared first.
|
|
725
|
+
def commit_entry(package, partial)
|
|
726
|
+
remove_existing_package_file(package) if vendored_package_path(package).directory?
|
|
727
|
+
File.rename(partial, vendored_package_path(package))
|
|
415
728
|
end
|
|
416
729
|
|
|
417
730
|
def remove_sourcemap_comment_from(source)
|
|
418
731
|
source.gsub(/^\/\/# sourceMappingURL=.*/, "")
|
|
419
732
|
end
|
|
420
733
|
|
|
421
|
-
def vendored_package_path(package)
|
|
422
|
-
@vendor_path.join(package_filename(package))
|
|
423
|
-
end
|
|
424
|
-
|
|
425
734
|
def package_filename(package)
|
|
426
735
|
package.gsub("/", "--") + ".js"
|
|
427
736
|
end
|
|
737
|
+
|
|
738
|
+
# The vendored file's own name without its extension, so the pin that wrote
|
|
739
|
+
# the directory is the pin that owns it.
|
|
740
|
+
def graph_path(package)
|
|
741
|
+
@vendor_path.join(package_filename(package).delete_suffix(".js"))
|
|
742
|
+
end
|
|
428
743
|
end
|