importmap-plus 1.1.1 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +351 -0
- data/README.md +10 -2
- data/app/helpers/importmap/importmap_tags_helper.rb +3 -0
- data/lib/importmap/batch_resolver.rb +133 -0
- data/lib/importmap/commands.rb +333 -32
- data/lib/importmap/doctor.rb +331 -0
- data/lib/importmap/early_hints.rb +50 -0
- data/lib/importmap/engine.rb +3 -0
- data/lib/importmap/esm_run.rb +118 -0
- data/lib/importmap/graph.rb +126 -0
- data/lib/importmap/import_scanner.rb +53 -0
- data/lib/importmap/integrity.rb +25 -0
- data/lib/importmap/map.rb +38 -1
- data/lib/importmap/module_inspector.rb +200 -0
- data/lib/importmap/npm.rb +9 -0
- data/lib/importmap/package_graph.rb +299 -0
- data/lib/importmap/packager.rb +302 -110
- data/lib/importmap/provider_chain.rb +89 -0
- data/lib/importmap/vendored_graph.rb +214 -0
- data/lib/importmap/version.rb +1 -1
- metadata +12 -1
data/lib/importmap/packager.rb
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
116
|
-
#
|
|
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 +
|
|
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
|
|
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
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
#
|
|
220
|
-
# absolute /npm/... imports rewritten
|
|
221
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
else
|
|
469
|
-
handle_failure_response(response)
|
|
470
|
-
end
|
|
471
|
-
end
|
|
658
|
+
source = self.class.minifier.call(source) if minify
|
|
472
659
|
|
|
473
|
-
|
|
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
|
-
|
|
478
|
-
end
|
|
662
|
+
dependencies || []
|
|
479
663
|
end
|
|
480
664
|
|
|
481
|
-
#
|
|
482
|
-
#
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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
|
-
|
|
502
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
721
|
+
partial
|
|
519
722
|
end
|
|
520
723
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|