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.
@@ -2,18 +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:
13
21
  # Only the booleans: a hash string is tied to the file it was computed for,
14
22
  # so a rewrite that changes the URL has to drop it.
15
23
  INTEGRITY_OPTION_REGEXP = /integrity:\s*(true|false)\b/.freeze # :nodoc:
24
+ INTEGRITY_HASH_REGEXP = /integrity:\s*["'][^"']+["']/.freeze # :nodoc:
16
25
  REMOTE_URL_REGEXP = %r{\Ahttps?://}.freeze # :nodoc:
26
+ IDENTITY_ENCODING = { "Accept-Encoding" => "identity" }.freeze # :nodoc:
17
27
 
18
28
  PROVIDER_HOSTS = {
19
29
  "ga.jspm.io" => "jspm.io",
@@ -23,20 +33,6 @@ class Importmap::Packager
23
33
  "esm.sh" => "esm.sh"
24
34
  }.freeze # :nodoc:
25
35
 
26
- # jsDelivr's bundling endpoint (https://www.jsdelivr.com/esm): one minified
27
- # ESM file per package, with its dependencies referenced as /npm/dep@ver/+esm.
28
- ESM_RUN_PROVIDER = "esm.run".freeze # :nodoc:
29
- ESM_RUN_CDN = "https://cdn.jsdelivr.net/npm/".freeze # :nodoc:
30
- ESM_RUN_URL_REGEXP = %r{\Ahttps://cdn\.jsdelivr\.net/npm/.+/\+esm\z}.freeze # :nodoc:
31
- # An esm.run bundle's own imports: `from"/npm/dep@1.2.3/+esm"`, `import"…"`,
32
- # `import("…")`, `export … from"…"`. Anchored on the keyword so an ordinary
33
- # string that happens to look like a bundle URL is left alone. What it does
34
- # not do is parse JavaScript, so the same text inside a string or a comment
35
- # would still be rewritten — a jsDelivr bundle is esbuild output whose only
36
- # surviving comment is the banner, and a root-relative /npm/ URL is
37
- # meaningless anywhere but in one of its own imports.
38
- ESM_RUN_IMPORT_REGEXP =
39
- %r{((?:\bfrom|\bimport)\s*\(?\s*)(["'])/npm/((?:@[^/"'@]+/)?[^/"'@]+)@([^/"']+)((?:/[^"']*?)?)/\+esm\2}.freeze # :nodoc:
40
36
  # name[@version][/subpath] — a leading "@" distinguishes a scoped name
41
37
  # (@scope/pkg) from an unscoped name with a subpath (apexcharts/core).
42
38
  PACKAGE_SPEC_REGEXP = %r{\A(@[^@/]+/[^@/]+|[^@/]+)(?:@([^/]+))?(/.+)?\z}.freeze # :nodoc:
@@ -50,18 +46,58 @@ class Importmap::Packager
50
46
  # today rather than mistaken for a provider name.
51
47
  LOCK_DETAIL = "locked".freeze # :nodoc:
52
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:
53
59
  DEFAULT_PROVIDER = "jspm.io".freeze # :nodoc:
54
60
 
55
61
  Error = Class.new(StandardError)
56
62
  HTTPError = Class.new(Error)
57
- 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
58
97
 
59
98
  singleton_class.attr_accessor :endpoint
60
99
  self.endpoint = URI("https://api.jspm.io/generate")
61
100
 
62
- singleton_class.attr_accessor :esm_run_resolver
63
- self.esm_run_resolver = URI("https://data.jsdelivr.com/v1/packages/npm/")
64
-
65
101
  # CDNs reset connections and rate-limit bursts. Each request is tried this
66
102
  # many times, pausing retry_wait × attempt between tries, before it fails.
67
103
  # Shared with Importmap::Npm, which talks to the registry the same way.
@@ -75,6 +111,13 @@ class Importmap::Packager
75
111
  def retry_wait=(value)
76
112
  Importmap::HttpRetries.wait = value
77
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
78
121
  end
79
122
 
80
123
  # Anything responding to #call(source) => String. Defaults to the first of
@@ -87,13 +130,27 @@ class Importmap::Packager
87
130
 
88
131
  attr_reader :vendor_path
89
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
+
90
145
  def initialize(importmap_path = "config/importmap.rb", vendor_path: "vendor/javascript")
91
146
  @importmap_path = Pathname.new(importmap_path)
92
147
  @vendor_path = Pathname.new(vendor_path)
93
148
  end
94
149
 
95
150
  def import(*packages, env: "production", from: "jspm")
96
- 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)
97
154
 
98
155
  response = post_json({
99
156
  "install" => Array(packages),
@@ -106,24 +163,28 @@ class Importmap::Packager
106
163
  when "200"
107
164
  extract_parsed_response(response)
108
165
  when "404", "401"
166
+ @last_import_error = parse_service_error(response)
109
167
  nil
110
168
  else
111
169
  handle_failure_response(response)
112
170
  end
113
171
  end
114
172
 
115
- # A remote pin has no version comment unless it is locked; then the version
116
- # in its URL is written out so the lock has something to hold:
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:
117
176
  #
118
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)
119
179
  #
120
- def pin_for(package, url = nil, preloads: nil, integrity: nil, locked: false)
180
+ def pin_for(package, url = nil, preloads: nil, integrity: nil, locked: false, remote: nil)
121
181
  to = url ? %(, to: "#{url}") : ""
122
182
  preload_param = preload(preloads)
123
- integrity_param = integrity.nil? ? "" : %(, integrity: #{integrity})
124
- version = extract_package_version_from(url.to_s) if locked
183
+ integrity_param = integrity.nil? ? "" : %(, integrity: #{integrity.inspect})
184
+ version = extract_package_version_from(url.to_s) if locked || remote
125
185
 
126
- %(pin "#{package}") + to + preload_param + integrity_param + (version ? provenance_comment(version, locked: true) : "")
186
+ %(pin "#{package}") + to + preload_param + integrity_param +
187
+ (version ? provenance_comment(version, remote: remote, locked: locked) : "")
127
188
  end
128
189
 
129
190
  # The pin line for a vendored download. The version comment also records
@@ -133,17 +194,38 @@ class Importmap::Packager
133
194
  # pin "luxon" # @3.7.2
134
195
  # pin "luxon" # @3.7.2 (esm.run, minified, locked)
135
196
  #
136
- def vendored_pin_for(package, url, preloads = nil, minify: false, integrity: nil, locked: false)
197
+ def vendored_pin_for(package, url, preloads = nil, minify: false, integrity: nil, locked: false, vendored: false)
137
198
  filename = package_filename(package)
138
199
  version = extract_package_version_from(url)
139
200
  to = "#{package}.js" != filename ? filename : nil
140
201
 
141
202
  pin_for(package, to, preloads: preloads, integrity: integrity) +
142
- provenance_comment(version, provider: provider_for_url(url), minified: minify, locked: locked)
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
221
+
222
+ def remove_graph(package)
223
+ reload! if vendored_graph(package).remove
143
224
  end
144
225
 
145
226
  # What the pin's version comment says a package was built with:
146
- # { version:, provider:, minified:, locked: }, or nil for a pin without one.
227
+ # { version:, provider:, minified:, vendored:, remote:, locked: }, or nil for
228
+ # a pin without one.
147
229
  def pin_provenance(package)
148
230
  provenance_of(pin_line_for(package))
149
231
  end
@@ -170,6 +252,13 @@ class Importmap::Packager
170
252
  pin_provenance(package)&.dig(:locked) || false
171
253
  end
172
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
+
173
262
  # The import-map keys of every pin, in file order.
174
263
  def pinned_packages
175
264
  return [] unless @importmap_path.exist?
@@ -213,19 +302,52 @@ class Importmap::Packager
213
302
  importmap.match(Importmap::Map.pin_line_regexp_for(package))
214
303
  end
215
304
 
216
- # Downloads +url+ into vendor/javascript. With +minify: true+ the source is
217
- # run through .minifier first and the file header records it, so later
218
- # updates keep minifying. Returns the dependencies an esm.run bundle imports
219
- # as [package, url] pairs (empty for every other provider), with the bundle's
220
- # absolute /npm/... imports rewritten to bare specifiers on the way in.
221
- 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
+
222
317
  ensure_vendor_directory_exists
223
- remove_existing_package_file(package)
224
- 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})"
225
346
  end
226
347
 
227
348
  def remove(package)
228
349
  remove_existing_package_file(package)
350
+ remove_graph(package)
229
351
  remove_package_from_importmap(package)
230
352
  end
231
353
 
@@ -257,13 +379,27 @@ class Importmap::Packager
257
379
  { provider: provider == DEFAULT_PROVIDER ? nil : provider, minified: minify ? true : false }
258
380
  end
259
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
+
260
396
  def remote_pin?(package)
261
397
  options = extract_existing_pin_options(package)[package] || {}
262
398
  options[:to].to_s.match?(REMOTE_URL_REGEXP)
263
399
  end
264
400
 
265
401
  def provider_for_url(url)
266
- return ESM_RUN_PROVIDER if url.to_s.match?(ESM_RUN_URL_REGEXP)
402
+ return Importmap::EsmRun::PROVIDER if Importmap::EsmRun.url?(url)
267
403
 
268
404
  PROVIDER_HOSTS[URI(url.to_s).host]
269
405
  rescue URI::InvalidURIError
@@ -271,7 +407,7 @@ class Importmap::Packager
271
407
  end
272
408
 
273
409
  def esm_run?(provider)
274
- provider.to_s == ESM_RUN_PROVIDER
410
+ Importmap::EsmRun.provider?(provider)
275
411
  end
276
412
 
277
413
  # The import-map key a package spec pins: "apexcharts@7.1.0/core" pins
@@ -298,6 +434,12 @@ class Importmap::Packager
298
434
  name ? "#{name}#{version}#{subpath}" : "#{key}#{version}"
299
435
  end
300
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
+
301
443
  def remove_existing_package_file(package)
302
444
  FileUtils.rm_rf vendored_package_path(package)
303
445
  end
@@ -315,19 +457,37 @@ class Importmap::Packager
315
457
  minified = details.delete("minified") ? true : false
316
458
  locked = without_lock(details).size != details.size
317
459
 
318
- { version: match[1], provider: without_lock(details).first, minified: minified, locked: locked }
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 }
319
462
  end
320
463
 
321
464
  def without_lock(details)
322
465
  details.reject { |detail| detail.match?(LOCK_DETAIL_REGEXP) }
323
466
  end
324
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
+
325
483
  # " # @3.7.2 (esm.run, minified, locked)" — the details in their fixed
326
484
  # order, the parens only when there is something to say.
327
- def provenance_comment(version, provider: nil, minified: false, locked: false)
485
+ def provenance_comment(version, provider: nil, minified: false, vendored: false, remote: nil, locked: false)
328
486
  details = []
329
487
  details << provider if provider && provider != DEFAULT_PROVIDER
330
488
  details << "minified" if minified
489
+ details << VENDORED_DETAIL if vendored
490
+ details << (remote == true ? REMOTE_DETAIL : "#{REMOTE_DETAIL}: #{remote}") if remote
331
491
  details << LOCK_DETAIL if locked
332
492
 
333
493
  %( # @#{version.to_s.delete_prefix("@")}) + (details.any? ? %( (#{details.join(", ")})) : "")
@@ -378,16 +538,24 @@ class Importmap::Packager
378
538
  when "false"
379
539
  false
380
540
  when /^\[.*\]$/
381
- 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
382
545
  else
383
546
  value.gsub(/["']/, "")
384
547
  end
385
548
  end
386
549
 
387
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
+
388
556
  case Array(preloads)
389
557
  in []
390
- ""
558
+ %(, preload: [])
391
559
  in ["true"] | [true]
392
560
  %(, preload: true)
393
561
  in ["false"] | [false]
@@ -436,6 +604,16 @@ class Importmap::Packager
436
604
  nil
437
605
  end
438
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
+
439
617
  def importmap
440
618
  @importmap ||= File.read(@importmap_path)
441
619
  end
@@ -452,100 +630,114 @@ class Importmap::Packager
452
630
  File.open(@importmap_path, "w") do |file|
453
631
  with_lines_removed.each { |line| file.write(line) }
454
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!
455
637
  end
456
638
 
457
- def download_package_file(package, url, minify: false)
458
- 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)
459
649
 
460
- if response.code == "200"
461
- source = response.body.dup.force_encoding("UTF-8")
462
- source, dependencies = rewrite_esm_run_imports(source) if url.match?(ESM_RUN_URL_REGEXP)
463
- 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
464
652
 
465
- 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)
466
657
 
467
- dependencies || []
468
- else
469
- handle_failure_response(response)
470
- end
471
- end
658
+ source = self.class.minifier.call(source) if minify
472
659
 
473
- def save_vendored_package(package, url, source, minified: false)
474
- File.open(vendored_package_path(package), "w+") do |vendored_package|
475
- 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)
476
661
 
477
- vendored_package.write remove_sourcemap_comment_from(source).force_encoding("UTF-8")
478
- end
662
+ dependencies || []
479
663
  end
480
664
 
481
- # Turns import "/npm/dep@1.2.3/+esm" into import "dep" so the bundle
482
- # resolves through the import map, and lists what it needs pinned.
483
- def rewrite_esm_run_imports(source)
484
- dependencies = {}
485
- 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?
486
672
 
487
- rewritten = source.gsub(ESM_RUN_IMPORT_REGEXP) do
488
- keyword, quote, name, version, subpath = $1, $2, $3, $4, $5.to_s
489
- key = "#{name}#{subpath}"
490
- dependencies[key] ||= "#{ESM_RUN_CDN}#{name}@#{version}#{subpath}/+esm"
491
- versions[key] << version unless versions[key].include?(version)
492
- "#{keyword}#{quote}#{name}#{subpath}#{quote}"
493
- end
673
+ integrity = Importmap::Integrity.for(body)
494
674
 
495
- # An import map maps a bare specifier to one file, so a bundle that
496
- # imports the same package at two versions can only get the first one
497
- # it asked for. Say so rather than pick silently.
498
- versions.each do |key, seen|
499
- next if seen.one?
675
+ raise Unvendorable.new(inspection.reasons, integrity: integrity) unless inspection.vendorable?
676
+ raise NotAnEsModule.new(integrity: integrity)
677
+ end
500
678
 
501
- warn %(#{key} is imported at #{seen.join(", ")} by this bundle; pinning @#{seen.first}, an import map holds one version)
502
- 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
503
695
 
504
- [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
505
703
  end
506
704
 
507
- def import_from_esm_run(packages)
508
- imports = packages.to_h do |spec|
509
- name, requested, subpath = spec.match(PACKAGE_SPEC_REGEXP)&.captures
510
- raise Error, "Can't parse package spec #{spec.inspect}" unless name
511
-
512
- version = resolve_esm_run_version(name, requested)
513
- return nil unless version
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
514
710
 
515
- ["#{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
516
719
  end
517
720
 
518
- { imports: imports }
721
+ partial
519
722
  end
520
723
 
521
- def resolve_esm_run_version(name, requested)
522
- uri = self.class.esm_run_resolver.dup
523
- uri.path += "#{name}/resolved"
524
- uri.query = "specifier=#{URI.encode_www_form_component(requested)}" if requested
525
-
526
- response = with_retries("resolving #{uri}") { Net::HTTP.get_response(uri) }
527
-
528
- case response.code
529
- when "200"
530
- JSON.parse(response.body)["version"]
531
- when "404"
532
- nil
533
- else
534
- handle_failure_response(response)
535
- end
536
- rescue JSON::ParserError
537
- 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))
538
728
  end
539
729
 
540
730
  def remove_sourcemap_comment_from(source)
541
731
  source.gsub(/^\/\/# sourceMappingURL=.*/, "")
542
732
  end
543
733
 
544
- def vendored_package_path(package)
545
- @vendor_path.join(package_filename(package))
546
- end
547
-
548
734
  def package_filename(package)
549
735
  package.gsub("/", "--") + ".js"
550
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
551
743
  end