From 907b8955d158b57e4249d95f46fa894ab2148410 Mon Sep 17 00:00:00 2001 From: Ben Fairless Date: Mon, 24 Aug 2026 11:30:51 +0800 Subject: [PATCH] Compress uploaded code with gzip Wrap the tar upload in a Zlib::GzipWriter so scraper code is gzip-compressed before being posted to the server, cutting upload time and bandwidth. The gzip stream is finished (not closed) so the tempfile handle stays open and rewound for the upload, matching the existing tar trailer handling. The multipart content type becomes application/gzip. Note: morph.io's ApiController#run_remote currently unpacks the upload with Archive::Tar::Minitar.unpack on a plain tar, so it needs a matching change to decompress before this can be released. Resolves #1 Assisted-by: OpenCode:anthropic.claude-fable-5 Signed-off-by: Ben Fairless --- lib/morph-cli.rb | 22 ++++++++++++---------- spec/morph_cli_spec.rb | 24 ++++++++++++++++++------ 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/lib/morph-cli.rb b/lib/morph-cli.rb index ddadb4d..9fc0bd4 100644 --- a/lib/morph-cli.rb +++ b/lib/morph-cli.rb @@ -6,6 +6,7 @@ require 'tempfile' require 'fileutils' require 'filesize' +require 'zlib' require 'faraday' require 'faraday/multipart' require 'minitar' @@ -42,7 +43,7 @@ def self.execute(directory, _development, env_config) connection.post("/run") do |req| req.body = { api_key: env_config[:api_key], - code: Faraday::Multipart::FilePart.new(file, "application/octet-stream") + code: Faraday::Multipart::FilePart.new(file, "application/gzip") } req.options.timeout = timeout req.options.on_data = proc do |chunk, _overall_received_bytes, env| @@ -106,21 +107,22 @@ def self.in_directory(directory) FileUtils.cd(cwd) end - # Packs the given paths (relative to directory) into a tar file and returns - # an open, rewound file handle ready for upload. + # Packs the given paths (relative to directory) into a gzip-compressed tar + # file and returns an open, rewound file handle ready for upload. def self.create_tar(directory, paths) - tempfile = Tempfile.new(["morph", ".tar"]) + tempfile = Tempfile.new(["morph", ".tar.gz"]) tempfile.binmode in_directory(directory) do - output = Minitar::Output.new(tempfile) - paths.each do |entry| - Minitar.pack_file(entry, output) - end + gzip = Zlib::GzipWriter.new(tempfile) + output = Minitar::Output.new(gzip) + paths.each { |entry| Minitar.pack_file(entry, output) } ensure - # Writes the tar trailer and flushes without closing the underlying - # tempfile, so the returned handle stays open for reading. + # Closing the tar writer writes the tar trailer; finishing (not + # closing) the gzip stream writes the gzip trailer, leaving the + # underlying tempfile handle open for reading. output&.tar&.close + gzip&.finish end tempfile.flush diff --git a/spec/morph_cli_spec.rb b/spec/morph_cli_spec.rb index 66111de..79b25f5 100644 --- a/spec/morph_cli_spec.rb +++ b/spec/morph_cli_spec.rb @@ -30,7 +30,7 @@ def with_scraper_directory end end - it "posts the API key and the code as multipart form data" do + it "posts the API key and the gzipped code as multipart form data" do stub_request(:post, "https://morph.io/run").to_return(status: 200, body: "") with_scraper_directory do |dir| @@ -41,7 +41,7 @@ def with_scraper_directory expect(WebMock).to(have_requested(:post, "https://morph.io/run").with do |req| req.headers["Content-Type"].start_with?("multipart/form-data") && req.body.include?("secret-key") && - req.body.include?("scraper.rb") + req.body.b.include?("\x1f\x8b".b) # gzip magic bytes end) end @@ -122,7 +122,7 @@ def with_scraper_directory end describe ".create_tar" do - it "packs the given paths into a readable tar" do + it "packs the given paths into a readable gzip-compressed tar" do Dir.mktmpdir do |dir| File.write(File.join(dir, "scraper.rb"), "puts 'hi'\n") FileUtils.mkdir_p(File.join(dir, "lib")) @@ -132,13 +132,25 @@ def with_scraper_directory tar = described_class.create_tar(dir, paths) names = [] - Minitar::Input.open(tar.path) do |input| - input.each { |entry| names << entry.full_name } + Zlib::GzipReader.open(tar.path) do |gzip| + Minitar::Input.open(gzip) do |input| + input.each { |entry| names << entry.full_name } + end end expect(names).to contain_exactly("scraper.rb", "lib/helper.rb") end end + it "compresses the tar" do + Dir.mktmpdir do |dir| + File.write(File.join(dir, "scraper.rb"), "a" * 100_000) + + tar = described_class.create_tar(dir, described_class.all_paths(dir)) + + expect(File.size(tar.path)).to be < 100_000 + end + end + it "returns an open file handle ready for reading" do Dir.mktmpdir do |dir| File.write(File.join(dir, "scraper.rb"), "puts 'hi'\n") @@ -147,7 +159,7 @@ def with_scraper_directory expect(tar).not_to be_closed expect(tar.pos).to eq(0) - expect(tar.read).to include("scraper.rb") + expect(tar.read(2)).to eq("\x1f\x8b".b) # gzip magic bytes end end end