Skip to content

Commit e0223fa

Browse files
author
David Heinemeier Hansson
committed
Offer option to download files from the CDN instead
1 parent f6c163e commit e0223fa

File tree

4 files changed

+109
-12
lines changed

4 files changed

+109
-12
lines changed

README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,34 @@ pin "react", to: "https://cdn.skypack.dev/react"
110110
```
111111

112112

113-
## What if I don't like to use a JavaScript CDN?
113+
## Downloading vendor files from the JavaScript CDN
114114

115-
You always have the option to simply download the compiled JavaScript packages from the CDNs, and saving them locally in your application. You can put such files in app/javascript/vendor, and then reference them with local pins, like:
115+
If you don't want to use a JavaScript CDN in production, you can also download vendored files from the CDN when you're setting up your pins:
116+
117+
```bash
118+
./bin/importmap pin react --download
119+
Pinning "react" to vendor/react.js via download from https://ga.jspm.io/npm:[email protected]/index.js
120+
Pinning "object-assign" to vendor/object-assign.js via download from https://ga.jspm.io/npm:[email protected]/index.js
121+
```
122+
123+
This will produce pins in your `config/importmap.rb` like so:
116124

117125
```ruby
118-
# config/importmap.rb
119-
pin "react", to: "vendor/[email protected]"
126+
pin "react", to: "vendor/react.js" # https://ga.jspm.io/npm:[email protected]/index.js
127+
pin "object-assign", to: "vendor/object-assign.js" # https://ga.jspm.io/npm:[email protected]/index.js
128+
```
129+
130+
The packages are downloaded to `app/javascript/vendor`, which you can check into your source control, and they'll be available through your application's own asset pipeline serving.
131+
132+
If you later wish to remove a downloaded pin, you again pass `--download`:
133+
134+
```bash
135+
./bin/importmap unpin react --download
136+
Unpinning and removing "react"
137+
Unpinning and removing "object-assign"
120138
```
121139

122-
But using a JavaScript CDN is fast, secure, and easier to deal with. Start there.
140+
Just like with a normal pin, you can also update a pin by running the `pin --download` command again.
123141

124142

125143
## Preloading pinned modules

lib/importmap/commands.rb

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,18 @@ class Importmap::Commands < Thor
77
desc "pin [*PACKAGES]", "Pin new packages"
88
option :env, type: :string, aliases: :e, default: "production"
99
option :from, type: :string, aliases: :f, default: "jspm"
10+
option :download, type: :boolean, aliases: :d, default: false
1011
def pin(*packages)
1112
if imports = packager.import(*packages, env: options[:env], from: options[:from])
1213
imports.each do |package, url|
13-
puts %(Pinning "#{package}" to #{url})
14-
15-
pin = packager.pin_for(package, url)
14+
if options[:download]
15+
puts %(Pinning "#{package}" to vendor/#{package}.js via download from #{url})
16+
packager.download(package, url)
17+
pin = packager.vendored_pin_for(package, url)
18+
else
19+
puts %(Pinning "#{package}" to #{url})
20+
pin = packager.pin_for(package, url)
21+
end
1622

1723
if packager.packaged?(package)
1824
gsub_file("config/importmap.rb", /^pin "#{package}".*$/, pin, verbose: false)
@@ -28,11 +34,18 @@ def pin(*packages)
2834
desc "unpin [*PACKAGES]", "Unpin existing packages"
2935
option :env, type: :string, aliases: :e, default: "production"
3036
option :from, type: :string, aliases: :f, default: "jspm"
37+
option :download, type: :boolean, aliases: :d, default: false
3138
def unpin(*packages)
3239
if imports = packager.import(*packages, env: options[:env], from: options[:from])
3340
imports.each do |package, url|
3441
if packager.packaged?(package)
35-
puts %(Unpinning "#{package}")
42+
if options[:download]
43+
puts %(Unpinning and removing "#{package}")
44+
packager.remove(package)
45+
else
46+
puts %(Unpinning "#{package}")
47+
end
48+
3649
remove_line_from_file "config/importmap.rb", /pin "#{package}"/
3750
end
3851
end

lib/importmap/packager.rb

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,9 @@ class Importmap::Packager
1010
singleton_class.attr_accessor :endpoint
1111
self.endpoint = URI("https://api.jspm.io/generate")
1212

13-
def initialize(importmap_path = "config/importmap.rb")
13+
def initialize(importmap_path = "config/importmap.rb", vendor_path: Pathname.new("app/javascript/vendor"))
1414
@importmap_path = importmap_path
15+
@vendor_path = vendor_path
1516
end
1617

1718
def import(*packages, env: "production", from: "jspm")
@@ -33,15 +34,29 @@ def pin_for(package, url)
3334
%(pin "#{package}", to: "#{url}")
3435
end
3536

37+
def vendored_pin_for(package, url)
38+
%(pin "#{package}", to: "vendor/#{package_filename(package)}" # #{url})
39+
end
40+
3641
def packaged?(package)
3742
importmap.match(/^pin "#{package}".*$/)
3843
end
3944

45+
def download(package, url)
46+
ensure_vendor_directory_exists
47+
remove_existing_package_file(package)
48+
download_package_file(package, url)
49+
end
50+
51+
def remove(package)
52+
remove_existing_package_file(package)
53+
end
54+
4055
private
4156
def extract_parsed_imports(response)
4257
JSON.parse(response.body).dig("map", "imports")
4358
end
44-
59+
4560
def handle_failure_response(response)
4661
if error_message = parse_service_error(response)
4762
raise ServiceError, error_message
@@ -65,4 +80,41 @@ def post_json(body)
6580
def importmap
6681
@importmap ||= File.read(@importmap_path)
6782
end
83+
84+
85+
def ensure_vendor_directory_exists
86+
FileUtils.mkdir_p @vendor_path
87+
end
88+
89+
def remove_existing_package_file(package)
90+
FileUtils.rm_rf vendored_package_path(package)
91+
FileUtils.rm_rf "#{vendored_package_path(package)}.br"
92+
end
93+
94+
def download_package_file(package, url)
95+
if url =~ /jspm.io/
96+
# Temporary workaround jspm.io only sending brotli
97+
`curl -s '#{url}' | brotli -d > #{vendored_package_path(package)}`
98+
else
99+
response = Net::HTTP.get_response(URI(url))
100+
101+
if response.code == "200"
102+
save_vendored_package(package, response.body)
103+
else
104+
handle_failure_response(response)
105+
end
106+
end
107+
end
108+
109+
def save_vendored_package(package, content)
110+
File.open(vendored_package_path(package), "w+") { |f| f.write(content) }
111+
end
112+
113+
def vendored_package_path(package)
114+
@vendor_path.join(package_filename(package))
115+
end
116+
117+
def package_filename(package)
118+
"#{package.gsub("/", "-")}.js"
119+
end
68120
end

test/packager_integration_test.rb

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
class Importmap::PackagerIntegrationTest < ActiveSupport::TestCase
55
setup { @packager = Importmap::Packager.new(Rails.root.join("config/importmap.rb")) }
66

7-
test "successful against live service" do
7+
test "successful import against live service" do
88
assert_equal "https://ga.jspm.io/npm:[email protected]/index.js", @packager.import("[email protected]")["react"]
99
end
1010

@@ -22,4 +22,18 @@ class Importmap::PackagerIntegrationTest < ActiveSupport::TestCase
2222
ensure
2323
Importmap::Packager.endpoint = original_endpoint
2424
end
25+
26+
test "successful download from live service" do
27+
Dir.mktmpdir do |vendor_dir|
28+
@packager = Importmap::Packager.new \
29+
Rails.root.join("config/importmap.rb"),
30+
vendor_path: Pathname.new(vendor_dir)
31+
32+
@packager.download("react", "https://ga.jspm.io/npm:[email protected]/index.js")
33+
assert File.exist?(Pathname.new(vendor_dir).join("react.js"))
34+
35+
@packager.remove("react")
36+
assert_not File.exist?(Pathname.new(vendor_dir).join("react.js"))
37+
end
38+
end
2539
end

0 commit comments

Comments
 (0)