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.
@@ -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
- PRELOAD_OPTION_REGEXP = /preload:\s*(\[[^\]]+\]|true|false|["'][^"']*["'])/.freeze # :nodoc:
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 = Error.new(Error)
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
- return import_from_esm_run(packages) if esm_run?(from)
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
- def pin_for(package, url = nil, preloads: nil)
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
- %(pin "#{package}") + to + preload_param
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 and whether the file was minified, so a later
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
- provenance = []
126
- provenance << provider_for_url(url) if provider_for_url(url) && provider_for_url(url) != DEFAULT_PROVIDER
127
- provenance << "minified" if minify
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
- pin_for(package, to, preloads: preloads) + %( # #{version}) + (provenance.any? ? %( (#{provenance.join(", ")})) : "")
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 vendored package was built with:
133
- # { version:, provider:, minified: }, or nil for a pin without one.
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
- line = importmap.lines.find { |candidate| candidate.match?(Importmap::Map.pin_line_regexp_for(package)) }
138
- match = line&.match(PIN_PROVENANCE_REGEXP)
139
- return unless match
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
- details = match[2].to_s.split(",").map(&:strip)
142
- minified = details.delete("minified") ? true : false
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
- { version: match[1], provider: details.first, minified: minified }
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. With +minify: true+ the source is
152
- # run through .minifier first and the file header records it, so later
153
- # updates keep minifying. Returns the dependencies an esm.run bundle imports
154
- # as [package, url] pairs (empty for every other provider), with the bundle's
155
- # absolute /npm/... imports rewritten to bare specifiers on the way in.
156
- def download(package, url, minify: false)
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
- remove_existing_package_file(package)
159
- download_package_file(package, url, minify: minify)
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 ESM_RUN_PROVIDER if url.to_s.match?(ESM_RUN_URL_REGEXP)
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.to_s == ESM_RUN_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.parse(value)
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
- def download_package_file(package, url, minify: false)
335
- response = with_retries("downloading #{url}") { Net::HTTP.get_response(URI(url)) }
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
- if response.code == "200"
338
- source = response.body.dup.force_encoding("UTF-8")
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
- save_vendored_package(package, url, source, minified: minify)
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
- dependencies || []
345
- else
346
- handle_failure_response(response)
347
- end
348
- end
658
+ source = self.class.minifier.call(source) if minify
349
659
 
350
- def save_vendored_package(package, url, source, minified: false)
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
- vendored_package.write remove_sourcemap_comment_from(source).force_encoding("UTF-8")
355
- end
662
+ dependencies || []
356
663
  end
357
664
 
358
- # Turns import "/npm/dep@1.2.3/+esm" into import "dep" so the bundle
359
- # resolves through the import map, and lists what it needs pinned.
360
- def rewrite_esm_run_imports(source)
361
- dependencies = {}
362
- versions = Hash.new { |hash, key| hash[key] = [] }
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
- rewritten = source.gsub(ESM_RUN_IMPORT_REGEXP) do
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
- # An import map maps a bare specifier to one file, so a bundle that
373
- # imports the same package at two versions can only get the first one
374
- # it asked for. Say so rather than pick silently.
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
- warn %(#{key} is imported at #{seen.join(", ")} by this bundle; pinning @#{seen.first}, an import map holds one version)
379
- end
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
- [rewritten, dependencies.to_a]
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
- def import_from_esm_run(packages)
385
- imports = packages.to_h do |spec|
386
- name, requested, subpath = spec.match(PACKAGE_SPEC_REGEXP)&.captures
387
- raise Error, "Can't parse package spec #{spec.inspect}" unless name
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
- version = resolve_esm_run_version(name, requested)
390
- return nil unless version
391
-
392
- ["#{name}#{subpath}", "#{ESM_RUN_CDN}#{name}@#{version}#{subpath}/+esm"]
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
- { imports: imports }
721
+ partial
396
722
  end
397
723
 
398
- def resolve_esm_run_version(name, requested)
399
- uri = self.class.esm_run_resolver.dup
400
- uri.path += "#{name}/resolved"
401
- uri.query = "specifier=#{URI.encode_www_form_component(requested)}" if requested
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