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
|
@@ -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
|
@@ -43,6 +43,15 @@ class Importmap::Npm
|
|
|
43
43
|
end.sort_by(&:name)
|
|
44
44
|
end
|
|
45
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
|
+
|
|
46
55
|
def vulnerable_packages
|
|
47
56
|
get_audit.flat_map do |package, vulnerabilities|
|
|
48
57
|
vulnerabilities.map do |vulnerability|
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "importmap/module_inspector"
|
|
3
|
+
require "importmap/integrity"
|
|
4
|
+
require "importmap/vendored_graph"
|
|
5
|
+
|
|
6
|
+
# The sibling files a chunked ESM download needs, fetched from the same CDN
|
|
7
|
+
# directory and rewritten so the import map can serve them.
|
|
8
|
+
#
|
|
9
|
+
# A published package that splits itself across files — +import"./_/f08a6ffe.js"+
|
|
10
|
+
# — cannot be vendored as the one file an import map entry points at: Propshaft
|
|
11
|
+
# and Sprockets digest file names and neither rewrites +import+ statements, so
|
|
12
|
+
# the browser asks for a path that no longer exists. The files are a closed set,
|
|
13
|
+
# though: everything an entry reaches through relative imports lives under the
|
|
14
|
+
# package's own version directory on the CDN. This crawls that set, turns every
|
|
15
|
+
# relative specifier into the bare key +<package>/<path without extension>+, and
|
|
16
|
+
# hands back the files for Importmap::Packager to write into a directory that
|
|
17
|
+
# one +pin_all_from+ line maps.
|
|
18
|
+
#
|
|
19
|
+
# Nothing is written here and nothing is written by the caller until the crawl
|
|
20
|
+
# has finished: a file it can't own — a path that escapes the package root, a
|
|
21
|
+
# sibling that isn't JavaScript, a chunk that spawns a worker — raises
|
|
22
|
+
# Unownable, and the whole package stays pinned to its CDN, which works.
|
|
23
|
+
class Importmap::PackageGraph
|
|
24
|
+
# The one Importmap::ModuleInspector reason a graph answers for. Every other
|
|
25
|
+
# reason is about a file the browser fetches by a path nobody can rewrite.
|
|
26
|
+
REASON = "relative imports".freeze # :nodoc:
|
|
27
|
+
|
|
28
|
+
# The CDNs whose URLs say which package and version a file belongs to, and so
|
|
29
|
+
# where the package's directory ends. esm.sh and skypack serve a package from
|
|
30
|
+
# paths that don't spell that out, so a download from them is left remote.
|
|
31
|
+
ROOT_REGEXPS = [
|
|
32
|
+
%r{\Ahttps://ga\.jspm\.io/npm:((?:@[^/@]+/)?[^/@]+)@([^/]+)/},
|
|
33
|
+
%r{\Ahttps://cdn\.jsdelivr\.net/npm/((?:@[^/@]+/)?[^/@]+)@([^/]+)/},
|
|
34
|
+
%r{\Ahttps://unpkg\.com/((?:@[^/@]+/)?[^/@]+)@([^/]+)/}
|
|
35
|
+
].map(&:freeze).freeze # :nodoc:
|
|
36
|
+
|
|
37
|
+
# Importmap::ModuleInspector::RELATIVE_IMPORT_REGEXP with the specifier
|
|
38
|
+
# captured, so the same forms it counts are the ones rewritten here. Like
|
|
39
|
+
# that one and Importmap::EsmRun::IMPORT_REGEXP it doesn't parse JavaScript,
|
|
40
|
+
# so a data string that spells out an import statement is rewritten inside
|
|
41
|
+
# the string too, and a form it can't read — a magic comment between the
|
|
42
|
+
# keyword and the specifier, an unterminated literal — isn't rewritten at
|
|
43
|
+
# all. #verify_rewritten is what makes the second kind safe: a file the crawl
|
|
44
|
+
# reached and couldn't rewrite keeps the whole package remote.
|
|
45
|
+
IMPORT_REGEXP =
|
|
46
|
+
/((?<![\w.$])(?:from|import)\s*\(?\s*)(["'])(\.{1,2}\/[^"'\n]*)\2/.freeze # :nodoc:
|
|
47
|
+
|
|
48
|
+
# A path under the package root that can become a file in vendor/javascript
|
|
49
|
+
# and a key in the import map: no query, no fragment, nothing but a plain
|
|
50
|
+
# relative path, and an extension Importmap::Map's directory glob picks up.
|
|
51
|
+
#
|
|
52
|
+
# Every segment is a plain name that doesn't begin with a dot, which rejects
|
|
53
|
+
# four paths a CDN can hand back and this class must not act on: "..", which
|
|
54
|
+
# climbs out of the package; an empty segment, as "a//b" has, whose key would
|
|
55
|
+
# be one Importmap::Map never emits for the file it writes; a leading "/",
|
|
56
|
+
# which Pathname#join turns into an absolute path outside vendor/javascript
|
|
57
|
+
# altogether; and a dot-directory, which Map's `**/*.js{,m}` glob doesn't
|
|
58
|
+
# descend into, so its files would be written and never mapped.
|
|
59
|
+
PATH_REGEXP =
|
|
60
|
+
%r{\A[A-Za-z0-9_@+\-][A-Za-z0-9._@+\-]*(?:/[A-Za-z0-9_@+\-][A-Za-z0-9._@+\-]*)*\.m?js\z}.freeze # :nodoc:
|
|
61
|
+
|
|
62
|
+
# Importmap::Map's directory expansion drops a trailing "index" from a key,
|
|
63
|
+
# so lib/index.js is reached as "<package>/lib" and index.js as "<package>".
|
|
64
|
+
INDEX_REGEXP = %r{(?:/|\A)index\z}.freeze # :nodoc:
|
|
65
|
+
|
|
66
|
+
# A file the crawl reached and can't take responsibility for. Translated into
|
|
67
|
+
# Importmap::Packager::Unvendorable, which carries the hash of the bytes the
|
|
68
|
+
# CDN served so the pin kept remote doesn't fetch them again.
|
|
69
|
+
class Unownable < StandardError
|
|
70
|
+
attr_reader :reasons
|
|
71
|
+
|
|
72
|
+
def initialize(reasons)
|
|
73
|
+
@reasons = Array(reasons)
|
|
74
|
+
super("needs more than its file graph (#{@reasons.join(", ")})")
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# The graph +source+ needs beside it, or nil when this download is one file:
|
|
79
|
+
# it imports no siblings, it isn't an ES module, or it comes from a CDN whose
|
|
80
|
+
# package directory can't be addressed. +package+ is the import-map key the
|
|
81
|
+
# entry itself is pinned under, +known+ maps the CDN URL of every file
|
|
82
|
+
# another pin already vendored to that pin's key, and +forbidden+ lists the
|
|
83
|
+
# keys the directory must not define. The block fetches a URL and answers nil
|
|
84
|
+
# when the CDN hasn't got it.
|
|
85
|
+
def self.build(url, source, package:, known: {}, forbidden: [], &fetcher)
|
|
86
|
+
root, name = root_and_package(url).values_at(0, 1)
|
|
87
|
+
return unless root
|
|
88
|
+
|
|
89
|
+
inspection = Importmap::ModuleInspector.new(source)
|
|
90
|
+
return unless inspection.reasons.include?(REASON) && inspection.es_module?
|
|
91
|
+
|
|
92
|
+
# The graph answers for the relative imports; anything else the entry does
|
|
93
|
+
# is why the package still can't be vendored, so only that is reported.
|
|
94
|
+
raise Unownable.new(inspection.reasons - [ REASON ]) unless inspection.reasons == [ REASON ]
|
|
95
|
+
|
|
96
|
+
new(root, name, url, source, package: package, known: known, forbidden: forbidden, &fetcher).crawl
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The graph a Packager download needs beside it, or nil when it is one file.
|
|
100
|
+
# A file the crawl can't own keeps the whole package remote, carrying the
|
|
101
|
+
# hash of the bytes the CDN served — as served, so the pin doesn't fetch them
|
|
102
|
+
# a second time. Every URL another pin already vendored is passed in as one
|
|
103
|
+
# the graph resolves to that pin's key rather than copies, and every key
|
|
104
|
+
# another pin owns as one it may not define.
|
|
105
|
+
def self.for_download(packager, package, url, source, body)
|
|
106
|
+
pins = packager.pinned_packages
|
|
107
|
+
known = Importmap::VendoredGraph.entry_urls(pins.to_h { |key| [ key, packager.vendored_package_path(key) ] })
|
|
108
|
+
|
|
109
|
+
build(url, source, package: package, known: known, forbidden: pins - [ package ]) do |file_url|
|
|
110
|
+
# Tagged the way the entry is: Net::HTTP hands back ASCII-8BIT, which
|
|
111
|
+
# neither the rewrite's regexes nor the write can read as text.
|
|
112
|
+
#
|
|
113
|
+
# Only a 404 answers with nil, and only a 404 means the crawl can't own
|
|
114
|
+
# the package. A 503 that outlives the retries is a fact about the CDN,
|
|
115
|
+
# not about the package: swallowing it here would keep a working vendored
|
|
116
|
+
# package remote and delete the files that made it work. It is raised,
|
|
117
|
+
# and the CLI reports it and leaves the pin alone.
|
|
118
|
+
packager.fetch_remote(file_url, allow_missing: true)&.force_encoding("UTF-8")
|
|
119
|
+
end
|
|
120
|
+
rescue Unownable => refusal
|
|
121
|
+
raise Importmap::Packager::Unvendorable.new(refusal.reasons, integrity: Importmap::Integrity.for(body))
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# The package a CDN URL names, or nil for a CDN whose paths don't say which
|
|
125
|
+
# package and version a file belongs to.
|
|
126
|
+
def self.package_for(url)
|
|
127
|
+
package_and_version_for(url).first
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# That package and the version the URL pins it at — the version the graph is
|
|
131
|
+
# of, and the fallback for a line's comment when the version isn't the
|
|
132
|
+
# semver Packager#extract_package_version_from looks for.
|
|
133
|
+
def self.package_and_version_for(url)
|
|
134
|
+
root_and_package(url).values_at(1, 2)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The package's own version directory on the CDN, the package it holds and
|
|
138
|
+
# that package's version, as a MatchData ([] when the CDN is one whose paths
|
|
139
|
+
# don't say).
|
|
140
|
+
def self.root_and_package(url)
|
|
141
|
+
ROOT_REGEXPS.filter_map { |regexp| url.to_s.match(regexp) }.first || []
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The package the CDN URL names, which is the prefix every key the directory
|
|
145
|
+
# defines is written under. Not always the package the pin's key names: jspm
|
|
146
|
+
# resolves Node's "buffer" to a file in @jspm/core.
|
|
147
|
+
attr_reader :under
|
|
148
|
+
|
|
149
|
+
# { "lib/enums.js" => source }, relative to the directory the caller writes,
|
|
150
|
+
# with every specifier rewritten. An .mjs sibling is named .js here, because
|
|
151
|
+
# Importmap::Map's directory glob doesn't look for .mjs.
|
|
152
|
+
attr_reader :files
|
|
153
|
+
|
|
154
|
+
attr_reader :entry_source
|
|
155
|
+
|
|
156
|
+
def initialize(root, under, url, source, package:, known: {}, forbidden: [], &fetcher)
|
|
157
|
+
@root, @under, @entry_url, @entry_source = root, under, url, source
|
|
158
|
+
@package, @known, @forbidden, @fetcher = package, known, forbidden, fetcher
|
|
159
|
+
@sources, @files, @keys = {}, {}, {}
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def crawl
|
|
163
|
+
discover
|
|
164
|
+
assign_keys
|
|
165
|
+
rewrite
|
|
166
|
+
verify_rewritten
|
|
167
|
+
|
|
168
|
+
self
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def size
|
|
172
|
+
files.size
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
# Breadth-first from the entry, following the relative imports each file
|
|
177
|
+
# makes in code — a specifier that only appears in a comment is not
|
|
178
|
+
# fetched, because a JSDoc @typedef names files a package never ships and
|
|
179
|
+
# asking the CDN for one 404s a package that vendors perfectly well.
|
|
180
|
+
def discover
|
|
181
|
+
@sources[@entry_url] = @entry_source
|
|
182
|
+
queue = [ @entry_url ]
|
|
183
|
+
|
|
184
|
+
until queue.empty?
|
|
185
|
+
url = queue.shift
|
|
186
|
+
|
|
187
|
+
imports_in(@sources[url], url).each do |target|
|
|
188
|
+
next if @sources.key?(target) || @known.key?(target)
|
|
189
|
+
|
|
190
|
+
@sources[target] = fetch(target)
|
|
191
|
+
queue << target
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def imports_in(source, url)
|
|
197
|
+
Importmap::ModuleInspector.new(source).code.scan(IMPORT_REGEXP).filter_map do |_keyword, _quote, specifier|
|
|
198
|
+
target = resolve(url, specifier)
|
|
199
|
+
|
|
200
|
+
raise Unownable.new(REASON) unless target&.start_with?(@root) && vendorable_path?(path_of(target))
|
|
201
|
+
|
|
202
|
+
target
|
|
203
|
+
end.uniq
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def fetch(url)
|
|
207
|
+
source = @fetcher.call(url)
|
|
208
|
+
|
|
209
|
+
# The CDN hasn't got the file the specifier names, so the browser
|
|
210
|
+
# wouldn't either: the crawl can't own this package.
|
|
211
|
+
raise Unownable.new(REASON) unless source
|
|
212
|
+
|
|
213
|
+
# A sibling may import siblings of its own — that is what a chunk does —
|
|
214
|
+
# but anything else it needs is as unreachable here as it is in the entry.
|
|
215
|
+
inspection = Importmap::ModuleInspector.new(source)
|
|
216
|
+
blockers = inspection.reasons - [ REASON ]
|
|
217
|
+
|
|
218
|
+
raise Unownable.new(blockers) if blockers.any?
|
|
219
|
+
raise Unownable.new("not an ES module") unless inspection.es_module?
|
|
220
|
+
|
|
221
|
+
source
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The entry keeps its own flat file and its own pin, so it is a key the
|
|
225
|
+
# graph resolves to rather than a file it writes; so is every file another
|
|
226
|
+
# pin already vendored, which must not be copied a second time — two copies
|
|
227
|
+
# of one module in an import map are two modules, evaluated twice.
|
|
228
|
+
def assign_keys
|
|
229
|
+
@keys = @known.merge(@entry_url => @package)
|
|
230
|
+
|
|
231
|
+
(@sources.keys - [ @entry_url ]).each do |url|
|
|
232
|
+
path = path_of(url).sub(/\.mjs\z/, ".js")
|
|
233
|
+
key = key_for(path)
|
|
234
|
+
|
|
235
|
+
# Two files under one key would give the import map one of them and
|
|
236
|
+
# lose the other; a key another pin owns would have the directory
|
|
237
|
+
# quietly take that pin's place, since pin_all_from wins over pin.
|
|
238
|
+
raise Unownable.new(REASON) if @keys.value?(key) || @forbidden.include?(key)
|
|
239
|
+
|
|
240
|
+
@files[path] = url
|
|
241
|
+
@keys[url] = key
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Two paths that differ only in case are one file on a case-insensitive
|
|
245
|
+
# filesystem, where the second write wins and one key serves the other
|
|
246
|
+
# module. Refusing is the same answer everywhere, rather than a package
|
|
247
|
+
# that vendors on Linux and misbehaves on a Mac.
|
|
248
|
+
raise Unownable.new(REASON) if @files.keys.map(&:downcase).uniq.size != @files.size
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Every relative import Importmap::ModuleInspector counted has to have come
|
|
252
|
+
# back as a bare key, or the file about to be written asks the browser for
|
|
253
|
+
# a path beside a digested asset. The rewrite reads the raw source while
|
|
254
|
+
# the crawl reads the source with block comments discounted, so a form the
|
|
255
|
+
# two disagree about — a magic comment between the keyword and the
|
|
256
|
+
# specifier — is caught here rather than shipped.
|
|
257
|
+
def verify_rewritten
|
|
258
|
+
sources = files.values + [ entry_source ]
|
|
259
|
+
|
|
260
|
+
raise Unownable.new(REASON) if sources.any? { |source| Importmap::ModuleInspector.new(source).reasons.include?(REASON) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def key_for(path)
|
|
264
|
+
suffix = path.chomp(File.extname(path)).sub(INDEX_REGEXP, "")
|
|
265
|
+
|
|
266
|
+
suffix.empty? ? @under : "#{@under}/#{suffix}"
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def rewrite
|
|
270
|
+
@entry_source = rewrite_specifiers(@sources[@entry_url], @entry_url)
|
|
271
|
+
@files.transform_values! { |url| rewrite_specifiers(@sources[url], url) }
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# A specifier whose file the crawl owns becomes that file's key; anything
|
|
275
|
+
# else is left exactly as it was, which is how a relative path inside a
|
|
276
|
+
# comment survives untouched.
|
|
277
|
+
def rewrite_specifiers(source, url)
|
|
278
|
+
source.gsub(IMPORT_REGEXP) do
|
|
279
|
+
keyword, quote, specifier = $1, $2, $3
|
|
280
|
+
key = @keys[resolve(url, specifier)]
|
|
281
|
+
|
|
282
|
+
key ? "#{keyword}#{quote}#{key}#{quote}" : $&
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def resolve(url, specifier)
|
|
287
|
+
URI.join(url, specifier).to_s
|
|
288
|
+
rescue URI::Error
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def path_of(url)
|
|
293
|
+
url.delete_prefix(@root)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def vendorable_path?(path)
|
|
297
|
+
path.match?(PATH_REGEXP) && !path.split("/").include?("..")
|
|
298
|
+
end
|
|
299
|
+
end
|