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.
@@ -0,0 +1,53 @@
1
+ require "importmap/module_inspector"
2
+
3
+ # Every module specifier a JavaScript file names, in source order, with whether
4
+ # the browser resolves it while linking the module or later at runtime. What an
5
+ # import map has to define, read back out of the files the map serves.
6
+ #
7
+ # Like Importmap::ModuleInspector and Importmap::PackageGraph::IMPORT_REGEXP
8
+ # this reads the source with regexes rather than parsing JavaScript, so an
9
+ # import statement spelled out inside a string literal is reported too, and a
10
+ # form the regexp can't read — a magic comment between the keyword and the
11
+ # specifier, a specifier built at runtime — isn't reported at all. Both
12
+ # mistakes are the cautious direction for the one caller: a specifier reported
13
+ # that the browser never asks for costs a pin, and a specifier missed leaves
14
+ # the app exactly as broken as it was before anyone ran the check.
15
+ #
16
+ # Block comments are discounted first, through ModuleInspector#code, because a
17
+ # published bundle is full of `/** @typedef {import('./slide.js').Slide} */` —
18
+ # type annotations naming files the package never loads.
19
+ class Importmap::ImportScanner
20
+ Import = Struct.new(:specifier, :kind, keyword_init: true) # :nodoc:
21
+
22
+ # `import("x")` of a string literal, then every static spelling: the bare
23
+ # `import "x"` and the `from "x"` that ends `import a from "x"`,
24
+ # `import {a} from "x"`, `import * as a from "x"`, `export {a} from "x"` and
25
+ # `export * from "x"`. The lookbehind keeps `loader.import(` and identifiers
26
+ # ending in `import` or `from` out, and the dynamic branch comes first so an
27
+ # `import(` is never read as the bare form.
28
+ #
29
+ # The closing `[),]` on the dynamic branch is what makes a computed
30
+ # specifier — `import(name)`, `import(`./${lang}.js`)` — match nothing
31
+ # rather than match half of something; it is the same test
32
+ # Importmap::ModuleInspector::COMPUTED_IMPORT_REGEXP makes from the other
33
+ # side, and the two must agree about which files hold one.
34
+ IMPORT_REGEXP = /
35
+ (?<![\w.$])
36
+ (?:
37
+ import\s*\(\s*(["'])([^"'\n]*)\1\s*[),] |
38
+ (?:from|import)\s*(["'])([^"'\n]*)\3
39
+ )
40
+ /x.freeze # :nodoc:
41
+
42
+ attr_reader :source
43
+
44
+ def initialize(source)
45
+ @source = source.to_s
46
+ end
47
+
48
+ def imports
49
+ @imports ||= Importmap::ModuleInspector.new(source).code.scan(IMPORT_REGEXP).map do |_, dynamic, _, static|
50
+ dynamic ? Import.new(specifier: dynamic, kind: :dynamic) : Import.new(specifier: static, kind: :static)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,25 @@
1
+ require "digest"
2
+
3
+ # The subresource-integrity hash of a file, in the form the import map's
4
+ # integrity section and a modulepreload link take: "sha384-" and the digest in
5
+ # base64. Computed from the bytes a CDN hands back, which are the bytes the
6
+ # browser will hash when it loads the module.
7
+ #
8
+ # pack("m0") rather than Base64.strict_encode64: base64 stopped being a default
9
+ # gem in Ruby 3.4, and this gem depends on railties, activesupport and actionpack
10
+ # and nothing else.
11
+ module Importmap::Integrity
12
+ ALGORITHM = "sha384".freeze
13
+
14
+ class << self
15
+ def for(body)
16
+ "#{ALGORITHM}-#{[ Digest::SHA384.digest(body) ].pack("m0")}"
17
+ end
18
+
19
+ # A pin's integrity option is true, false, nil or a hash string; only the
20
+ # last is a value this computed, and only it is quoted and printed.
21
+ def hash?(integrity)
22
+ integrity.is_a?(String)
23
+ end
24
+ end
25
+ end
data/lib/importmap/map.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  require "pathname"
2
+ require "importmap/graph"
2
3
 
3
4
  class Importmap::Map
4
5
  attr_reader :packages, :directories
@@ -193,6 +194,14 @@ class Importmap::Map
193
194
  end
194
195
  end
195
196
 
197
+ # Yields every key the map defines and the +MappedFile+ it maps to, with the
198
+ # `pin_all_from` directories expanded into the keys they contribute — what
199
+ # #to_json is about to resolve, before a resolver has touched it. Returns an
200
+ # Enumerator without a block.
201
+ def each_expanded_package(&block)
202
+ expanded_packages_and_directories.each(&block)
203
+ end
204
+
196
205
  private
197
206
  MappedDir = Struct.new(:dir, :path, :under, :preload, :integrity, keyword_init: true)
198
207
  MappedFile = Struct.new(:name, :path, :preload, :integrity, keyword_init: true)
@@ -207,6 +216,7 @@ class Importmap::Map
207
216
 
208
217
  def clear_cache
209
218
  @cache.clear
219
+ @graph = nil
210
220
  end
211
221
 
212
222
  def rescuable_asset_error?(error)
@@ -265,7 +275,34 @@ class Importmap::Map
265
275
  end
266
276
 
267
277
  def expanded_preloading_packages_and_directories(entry_point:)
268
- expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
278
+ preloading = expanded_packages_and_directories.select { |name, mapping| mapping.preload.in?([true, false]) ? mapping.preload : (Array(mapping.preload) & Array(entry_point)).any? }
279
+ reachable_only(preloading, entry_point: entry_point)
280
+ end
281
+
282
+ # With config.importmap.preload_strategy == :reachable, a pin that says
283
+ # `preload: true` is preloaded only when the entry point's own imports
284
+ # reach it, so a package behind an `import()` stops being fetched on every
285
+ # page without anyone maintaining a `preload: false` for it and for
286
+ # everything it depends on. A pin naming the entry point is the app
287
+ # overruling the graph, and `preload: false` is off either way.
288
+ def reachable_only(packages, entry_point:)
289
+ return packages unless Rails.application.config.importmap.preload_strategy == :reachable
290
+
291
+ reachable = graph.reachable_from(Array(entry_point))
292
+ packages.select { |name, mapping| mapping.preload != true || reachable.include?(name) }
293
+ end
294
+
295
+ # Memoised beside the rendered map rather than inside it: #cache_as shares
296
+ # one namespace with the cache_key the preload helper passes, which is the
297
+ # entry point's own name, so an app with an entry point named "graph" would
298
+ # read this back as its preload set. Dropped by the same clear_cache the
299
+ # sweeper calls when a .js file under a watched directory changes.
300
+ def graph
301
+ @graph ||= Importmap::Graph.new(self, roots: asset_paths)
302
+ end
303
+
304
+ def asset_paths
305
+ (config = Rails.application.config).respond_to?(:assets) ? config.assets.paths : []
269
306
  end
270
307
 
271
308
  def expanded_packages_and_directories
@@ -0,0 +1,200 @@
1
+ require "strscan"
2
+
3
+ # Decides whether a downloaded ESM file can stand alone as the single file an
4
+ # import map entry points at. A vendored package is exactly one file served
5
+ # under a digested asset path, so anything the file expects to find beside
6
+ # itself — a sibling module, a worker script, a wasm binary, its own directory
7
+ # via import.meta.url — resolves to a 404 in the browser.
8
+ #
9
+ # Like Importmap::EsmRun::IMPORT_REGEXP this reads the source with regexes
10
+ # rather than parsing JavaScript, so the same text inside a string still counts.
11
+ # It is deliberately the cautious direction: a false positive keeps a working
12
+ # remote pin, and `pin --vendor` is the escape hatch. Every judgement call here
13
+ # leans that way, because the two mistakes are not equal — a package wrongly
14
+ # kept remote still works, a package wrongly vendored 404s in production.
15
+ class Importmap::ModuleInspector
16
+ # `from "./x"`, `from '../x'`, a bare `import "./x"` and a dynamic
17
+ # `import("./x")`. Anchored on the keyword, and the lookbehind keeps
18
+ # `obj.import(` and identifiers ending in `import` out.
19
+ RELATIVE_IMPORT_REGEXP = /(?<![\w.$])(?:from|import)\s*\(?\s*["']\.{1,2}\//.freeze # :nodoc:
20
+ # import() of anything but a string literal: the specifier is computed at
21
+ # runtime, so what it resolves to can't be known here, let alone vendored.
22
+ # The whitespace lives inside the lookahead on purpose: as `import\s*\(\s*`
23
+ # followed by a negative lookahead, `\s*` backtracks to zero and the lookahead
24
+ # then reads the space rather than the quote, so `import( "crypt" )` reads as
25
+ # computed.
26
+ COMPUTED_IMPORT_REGEXP = /(?<![\w.$])import\s*\((?!\s*["'][^"']*["']\s*[),])/.freeze # :nodoc:
27
+ # A worker is fetched as its own top-level script and never goes through the
28
+ # import map, so its URL has to exist on its own.
29
+ # The qualifier group catches `new window.Worker(…)` and `new self.Worker(…)`;
30
+ # it needs the dot, so `new WorkerPool(…)` is still left alone.
31
+ WORKER_REGEXP = /(?<![\w.$])new\s+(?:[\w$]+\s*\.\s*)*(?:Shared)?Worker\s*\(/.freeze # :nodoc:
32
+ # import.meta.url is the file's own digested asset path, which is not the
33
+ # directory the package's other files were published to.
34
+ IMPORT_META_URL_REGEXP = /(?<![\w.$])import\s*\.\s*meta\s*\.\s*url\b/.freeze # :nodoc:
35
+ # A .wasm binary is fetched at runtime by a path the package computes; it is
36
+ # never part of the JavaScript file that names it. Template literals count —
37
+ # the path is usually built from a base — and so does a cache-busting query
38
+ # or a fragment after the extension.
39
+ WASM_REGEXP = /(["'`])[^"'`\n]*\.wasm(?:[?#][^"'`\n]*)?\1/.freeze # :nodoc:
40
+
41
+ # In precedence order: the first one that matches is the reason reported, and
42
+ # relative imports come first because they are both the commonest cause and
43
+ # the one an app developer can act on.
44
+ PATTERNS = {
45
+ "relative imports" => RELATIVE_IMPORT_REGEXP,
46
+ "dynamic imports" => COMPUTED_IMPORT_REGEXP,
47
+ "workers" => WORKER_REGEXP,
48
+ "import.meta.url" => IMPORT_META_URL_REGEXP,
49
+ "wasm" => WASM_REGEXP
50
+ }.freeze # :nodoc:
51
+
52
+ # A string literal, consumed whole so nothing inside it is ever read as
53
+ # code. Unterminated, it simply doesn't match and the quote is stepped over.
54
+ STRING_REGEXP = /"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'|`(?:[^`\\]|\\.)*`/m.freeze # :nodoc:
55
+ # A regex literal, consumed whole for the same reason: `/[/*]/` otherwise
56
+ # hands the block-comment matcher an opener and loses the file to the next
57
+ # `*/`. The body allows an escape or a character class, since both can hold
58
+ # the delimiter.
59
+ REGEXP_LITERAL_REGEXP = %r{/(?![*/])(?:[^/\\\n\[]|\\.|\[(?:[^\]\\\n]|\\.)*\])+/[dgimsuvy]*}.freeze # :nodoc:
60
+ # Whether a `/` opens a regex literal or divides depends on the token before
61
+ # it. Getting it wrong can only keep text that should have been dropped —
62
+ # never drop text that should have been kept — so the cautious reading is
63
+ # the safe one here too. Only the tail of what has been kept is looked at:
64
+ # matching the whole buffer at every slash is quadratic, and pdf.js is a
65
+ # megabyte of minified source with a slash in every other line.
66
+ # `)` and `}` are in the set even though they also end an expression that a
67
+ # `/` would divide: reading `(a + b) / 2` as a regex keeps the text either
68
+ # way, while leaving them out lets `if (x) /[/*]/` open a false comment.
69
+ # Verified against 31 published packages — no verdict changes.
70
+ BEFORE_REGEXP_LITERAL_REGEXP =
71
+ /(?:[(,=:\[!&|?{};+\-*%~^<>)\}]|\b(?:return|throw|typeof|case|in|of|do|else|yield|await|delete|void|instanceof|new))\s*\z/.freeze # :nodoc:
72
+ BLOCK_COMMENT_REGEXP = %r{/\*.*?\*/}m.freeze # :nodoc:
73
+
74
+ # An ESM statement, in every spelling a published bundle uses: `import "x"`,
75
+ # `import a from "b"`, `import a, {b} from "c"`, `import{a}from"b"`,
76
+ # `export{a}`, `export * from "b"`, `export default`, and `export` in front
77
+ # of a declaration. The lookbehind keeps `obj.import(` and `reimport` out;
78
+ # the space the identifier form insists on keeps lodash's `importsKeys,` out,
79
+ # which the `exports` of a UMD wrapper needs no help with.
80
+ ESM_STATEMENT_REGEXP = /
81
+ (?<![\w.$])
82
+ (?:
83
+ import\s*["'{*] | import\s+[\w$]+\s*(?:,|\bfrom\b) |
84
+ export\s*(?:[{*]|\b(?:default|var|let|const|function|class|async)\b)
85
+ )
86
+ /x.freeze # :nodoc:
87
+ # What a CommonJS, AMD or UMD bundle says instead: it assigns to an
88
+ # `exports`, it requires or defines, or it sniffs for the loader it is
89
+ # running under. None of these is
90
+ # proof on its own — an ESM file may well mention `require(` — so they only
91
+ # decide a file that declares no exports of its own, where the only mistake
92
+ # they can make is sending a package on to the next CDN.
93
+ #
94
+ # The loader sniff has to be here because the assignment often isn't:
95
+ # lodash reaches its `exports` through `freeModule.exports`, and spells
96
+ # `module.exports` out only in a comment, which is stripped before any of
97
+ # this is read.
98
+ COMMONJS_REGEXP = /
99
+ (?<![\w.$])(?:module\s*\.\s*exports|exports\s*(?:\.\s*[\w$]+|\[[^\]]+\])\s*=|require\s*\(|define\s*\(|typeof\s+(?:exports|module|define)\s*[!=]=) |
100
+ \.\s*exports\s*=
101
+ /x.freeze # :nodoc:
102
+ LINE_COMMENT_REGEXP = %r{//[^\n]*}.freeze # :nodoc:
103
+
104
+ attr_reader :source
105
+
106
+ # The source as it would be written to vendor/javascript: after an esm.run
107
+ # bundle's imports have been rewritten to bare specifiers, before minifying.
108
+ def initialize(source)
109
+ @source = source.to_s
110
+ end
111
+
112
+ # Every pattern the source matches, in precedence order.
113
+ def reasons
114
+ @reasons ||= PATTERNS.filter_map { |reason, regexp| reason if code.match?(regexp) }
115
+ end
116
+
117
+ def reason
118
+ reasons.first
119
+ end
120
+
121
+ def vendorable?
122
+ reasons.empty?
123
+ end
124
+
125
+ # Whether the file is something an import map entry can resolve to: it says
126
+ # what it exports, or at least never says it is CommonJS. A UMD bundle loaded
127
+ # as a module runs and exports nothing, so `import x from "pkg"` fails to
128
+ # link — "The requested module does not provide an export named 'default'" —
129
+ # and takes every module that imported it down too, in the browser only.
130
+ #
131
+ # The two halves read different text, each in the direction that keeps a
132
+ # non-module out. An import statement counts only outside a string literal
133
+ # and outside a line comment, because a bundle that ships a usage example in
134
+ # either is still CommonJS; a `module.exports` counts wherever it appears,
135
+ # because a UMD wrapper hidden in a string is a UMD wrapper.
136
+ def es_module?
137
+ statements.match?(ESM_STATEMENT_REGEXP) || !code.match?(COMMONJS_REGEXP)
138
+ end
139
+
140
+ # The source with its block comments discounted: what every pattern above is
141
+ # matched against, and what Importmap::PackageGraph reads its specifiers out
142
+ # of, so the crawl follows exactly the imports this class counted.
143
+ def code
144
+ @code ||= without_block_comments
145
+ end
146
+
147
+ private
148
+ def statements
149
+ @statements ||= without_block_comments(statements_only: true)
150
+ end
151
+
152
+ # Block comments are discounted before anything is matched. A published
153
+ # bundle is full of `/** @typedef {import('./slide.js').Slide} Slide */` —
154
+ # type annotations naming files the package never loads, which would
155
+ # otherwise keep a self-contained package remote; photoswipe alone carries
156
+ # 76 of them.
157
+ #
158
+ # The scan walks the source instead of running a `/\*.*?\*/` over it,
159
+ # because that regex reads a `"/*"` inside a string as a comment opener and
160
+ # swallows the code up to the next `*/` — and an `import "./sibling.js"`
161
+ # swallowed there is precisely the 404 this class exists to catch. Strings
162
+ # are matched first and kept whole, so a comment opener inside one is never
163
+ # reached.
164
+ #
165
+ # Line comments are left alone. Stripping them would mean reading `//` as
166
+ # an opener inside a regex literal such as `[//]`, which is the same trap
167
+ # in the same dangerous direction, and nothing is known to hide behind one.
168
+ # With +statements_only+ the line comments go too, and every literal is
169
+ # emptied rather than kept — its delimiters stay, so `import "x"` still
170
+ # reads as an import statement while the text inside it stops being read
171
+ # as code at all. That mode feeds only the ES-module check, where the
172
+ # `[//]` trap above can at worst send a package on to the next CDN.
173
+ def without_block_comments(statements_only: false)
174
+ scanner = StringScanner.new(source)
175
+ kept = +""
176
+
177
+ until scanner.eos?
178
+ if scanner.skip(BLOCK_COMMENT_REGEXP) || (statements_only && scanner.skip(LINE_COMMENT_REGEXP))
179
+ next
180
+ elsif (literal = scanner.scan(STRING_REGEXP)) ||
181
+ (regexp_literal_next?(kept, scanner) && (literal = scanner.scan(REGEXP_LITERAL_REGEXP)))
182
+ kept << (statements_only ? literal[0, 1] * 2 : literal)
183
+ else
184
+ kept << scanner.getch
185
+ end
186
+ end
187
+
188
+ kept
189
+ end
190
+
191
+ # Wide enough for the longest keyword above plus the indentation a
192
+ # pretty-printed file can put between it and the slash; a keyword the
193
+ # window cuts in half simply reads as division, which keeps less.
194
+ REGEXP_LOOKBEHIND_LIMIT = 32 # :nodoc:
195
+
196
+ def regexp_literal_next?(kept, scanner)
197
+ scanner.match?(%r{/}) &&
198
+ (kept[-REGEXP_LOOKBEHIND_LIMIT..] || kept).match?(BEFORE_REGEXP_LITERAL_REGEXP)
199
+ end
200
+ end
data/lib/importmap/npm.rb CHANGED
@@ -19,8 +19,13 @@ class Importmap::Npm
19
19
  @vendor_path = Pathname.new(vendor_path)
20
20
  end
21
21
 
22
- def outdated_packages
23
- packages_with_versions.each_with_object([]) do |(package, current_version), outdated_packages|
22
+ # With +only:+, just those packages are looked up; names may carry a
23
+ # subpath (apexcharts/core), which the registry doesn't know about.
24
+ def outdated_packages(only: nil)
25
+ wanted = only&.map { |name| extract_base_package_name(name) }
26
+ candidates = wanted ? packages_with_versions.select { |package, _| wanted.include?(package) } : packages_with_versions
27
+
28
+ candidates.each_with_object([]) do |(package, current_version), outdated_packages|
24
29
  outdated_package = OutdatedPackage.new(name: package, current_version: current_version)
25
30
 
26
31
  if !(response = get_package(package))
@@ -38,6 +43,15 @@ class Importmap::Npm
38
43
  end.sort_by(&:name)
39
44
  end
40
45
 
46
+ # The version the registry calls latest, or nil when it couldn't be asked.
47
+ # The registry is authoritative about what a package's latest version is,
48
+ # where a CDN answers with the latest it happens to have built.
49
+ def latest_version(package)
50
+ response = get_package(package)
51
+
52
+ find_latest_version(response)&.to_s unless response.nil? || response["error"]
53
+ end
54
+
41
55
  def vulnerable_packages
42
56
  get_audit.flat_map do |package, vulnerabilities|
43
57
  vulnerabilities.map do |vulnerability|
@@ -51,21 +65,25 @@ class Importmap::Npm
51
65
  end.sort_by { |p| [p.name, p.severity] }
52
66
  end
53
67
 
68
+ # Memoized: a command that asks twice would otherwise report the
69
+ # unversioned packages twice.
54
70
  def packages_with_versions
55
- # We cannot use the name after "pin" because some dependencies are loaded from inside packages
56
- # Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.19/nodelibs/browser/buffer.js"
57
- with_versions = importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/|esm\.sh\/|esm\.sh\/\*)([^@\/]+)@(\d+\.\d+\.\d+(?:[^\/\s"']*))/) |
58
- importmap.scan(/#{PIN_REGEX} #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
59
-
60
- with_versions.map! do |package, version|
61
- [extract_base_package_name(package), version]
62
- end.uniq!
71
+ @packages_with_versions ||= begin
72
+ # We cannot use the name after "pin" because some dependencies are loaded from inside packages
73
+ # Eg. pin "buffer", to: "https://ga.jspm.io/npm:@jspm/core@2.0.0-beta.19/nodelibs/browser/buffer.js"
74
+ with_versions = importmap.scan(/^pin .*(?<=npm:|npm\/|skypack\.dev\/|unpkg\.com\/|esm\.sh\/|esm\.sh\/\*)([^@\/]+)@(\d+\.\d+\.\d+(?:[^\/\s"']*))/) |
75
+ importmap.scan(/#{PIN_REGEX} #.*@(\d+\.\d+\.\d+(?:[^\s]*)).*$/)
76
+
77
+ with_versions.map! do |package, version|
78
+ [extract_base_package_name(package), version]
79
+ end.uniq!
80
+
81
+ vendored_packages_without_version(with_versions).each do |package, path|
82
+ $stdout.puts "Ignoring #{package} (#{path}) since no version is specified in the importmap"
83
+ end
63
84
 
64
- vendored_packages_without_version(with_versions).each do |package, path|
65
- $stdout.puts "Ignoring #{package} (#{path}) since no version is specified in the importmap"
85
+ with_versions
66
86
  end
67
-
68
- with_versions
69
87
  end
70
88
 
71
89
  private
@@ -84,6 +102,11 @@ class Importmap::Npm
84
102
  JSON.parse(response)
85
103
  rescue JSON::ParserError
86
104
  nil
105
+ rescue HTTPError => error
106
+ # One package the registry won't answer for shouldn't end the run: the
107
+ # caller records it as unchecked, so the rest are still reported on.
108
+ # with_retries has already spent its attempts by the time we get here.
109
+ { "error" => error.message }
87
110
  end
88
111
 
89
112
  def get_json(uri)