rack-smart_compress 0.1.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,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "configuration"
4
+ require_relative "encoders/base"
5
+ require_relative "encoders/zstd"
6
+ require_relative "encoders/brotli"
7
+ require_relative "encoders/gzip"
8
+ require_relative "encoders/deflate"
9
+ require_relative "lru_cache"
10
+ require_relative "stream_body"
11
+ require_relative "cpu_advisor"
12
+
13
+ module Rack
14
+ module SmartCompress
15
+ class Middleware
16
+ ENCODERS = {
17
+ "zstd" => Encoders::Zstd,
18
+ "br" => Encoders::Brotli,
19
+ "gzip" => Encoders::Gzip,
20
+ "deflate" => Encoders::Deflate
21
+ }.freeze
22
+
23
+ attr_reader :app, :options, :cache
24
+
25
+ def initialize(app, options = {})
26
+ @app = app
27
+ default_config = Rack::SmartCompress.configuration.to_h
28
+ @options = default_config.merge(options)
29
+
30
+ @min_size = @options.fetch(:min_size, Configuration::DEFAULT_MIN_SIZE)
31
+ @mime_types = @options.fetch(:mime_types, Configuration::DEFAULT_MIME_TYPES)
32
+ @exclude_mime_types = @options.fetch(:exclude_mime_types, Configuration::DEFAULT_EXCLUDED_MIME_TYPES)
33
+ @enabled_encodings = @options.fetch(:encodings, Configuration::DEFAULT_ENCODINGS)
34
+ @if_condition = @options[:if]
35
+ @unless_condition = @options[:unless]
36
+ @dynamic_levels = @options.fetch(:dynamic_levels, false)
37
+ @instrumentation = @options.fetch(:instrumentation, true)
38
+
39
+ if @options[:cache] || @options[:cache_size]
40
+ cache_size = @options.is_a?(Integer) ? @options[:cache_size] : (@options[:cache_size] || LruCache::DEFAULT_MAX_SIZE)
41
+ @cache = LruCache.new(cache_size)
42
+ else
43
+ @cache = nil
44
+ end
45
+ end
46
+
47
+ def call(env)
48
+ status, headers, body = @app.call(env)
49
+
50
+ return [status, headers, body] if no_body_status?(status)
51
+ return [status, headers, body] if already_encoded?(headers)
52
+ return [status, headers, body] if no_transform?(headers)
53
+ return [status, headers, body] if partial_content?(status, headers)
54
+ return [status, headers, body] unless custom_rules_pass?(env, status, headers)
55
+
56
+ accept_encoding = env["HTTP_ACCEPT_ENCODING"].to_s
57
+ encoder_name, encoder_class = negotiate_encoder(accept_encoding)
58
+ return [status, headers, body] unless encoder_class
59
+
60
+ content_type = get_header(headers, "content-type")
61
+ return [status, headers, body] unless compressible_mime_type?(content_type)
62
+
63
+ level = options[:"#{encoder_name}_level"]
64
+ level = CpuAdvisor.adjusted_level(encoder_name, level) if @dynamic_levels
65
+
66
+ # Handle streaming responses
67
+ if streaming_body?(body) || options[:stream]
68
+ new_headers = headers.dup
69
+ set_header(new_headers, "content-encoding", encoder_name)
70
+ delete_header(new_headers, "content-length") # Chunked stream size is dynamic
71
+ append_vary(new_headers, "Accept-Encoding")
72
+ weaken_etag(new_headers)
73
+ stream_wrapper = StreamBody.new(body, encoder_name, encoder_class, level: level)
74
+ return [status, new_headers, stream_wrapper]
75
+ end
76
+
77
+ body_string = extract_body_string(body)
78
+ return [status, headers, body] if body_string.bytesize < @min_size
79
+
80
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+ compressed_body, compressed_size, cache_hit = compress_with_cache(encoder_name, encoder_class, level, body_string)
82
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000.0).round(3)
83
+
84
+ body.close if body.respond_to?(:close)
85
+
86
+ new_headers = headers.dup
87
+ set_header(new_headers, "content-encoding", encoder_name)
88
+ set_header(new_headers, "content-length", compressed_size.to_s)
89
+ append_vary(new_headers, "Accept-Encoding")
90
+ weaken_etag(new_headers)
91
+
92
+ record_telemetry(encoder_name, body_string.bytesize, compressed_size, duration_ms, cache_hit, env)
93
+
94
+ [status, new_headers, compressed_body]
95
+ end
96
+
97
+ private
98
+
99
+ def custom_rules_pass?(env, status, headers)
100
+ if @if_condition.respond_to?(:call)
101
+ return false unless @if_condition.call(env, status, headers)
102
+ end
103
+
104
+ if @unless_condition.respond_to?(:call)
105
+ return false if @unless_condition.call(env, status, headers)
106
+ end
107
+
108
+ true
109
+ end
110
+
111
+ def compress_with_cache(encoder_name, encoder_class, level, body_string)
112
+ if @cache
113
+ cache_key = @cache.build_key(encoder_name, level, body_string)
114
+ cache_hit = true
115
+ cached_result = @cache.get(cache_key)
116
+
117
+ if cached_result.nil?
118
+ cache_hit = false
119
+ res = encoder_class.encode_body(body_string, level: level)
120
+ cached_result = [res, res.first.bytesize]
121
+ @cache.put(cache_key, cached_result)
122
+ end
123
+
124
+ [cached_result[0], cached_result[1], cache_hit]
125
+ else
126
+ res = encoder_class.encode_body(body_string, level: level)
127
+ [res, res.first.bytesize, false]
128
+ end
129
+ end
130
+
131
+ def record_telemetry(encoder_name, original_size, compressed_size, duration_ms, cache_hit, env)
132
+ return unless @instrumentation
133
+
134
+ payload = {
135
+ encoder: encoder_name,
136
+ original_size: original_size,
137
+ compressed_size: compressed_size,
138
+ duration_ms: duration_ms,
139
+ cache_hit: cache_hit,
140
+ path: env["PATH_INFO"]
141
+ }
142
+
143
+ if defined?(ActiveSupport::Notifications)
144
+ ActiveSupport::Notifications.instrument("rack_smart_compress.compress", payload)
145
+ end
146
+
147
+ if @instrumentation.respond_to?(:call)
148
+ @instrumentation.call(payload)
149
+ end
150
+ end
151
+
152
+ def streaming_body?(body)
153
+ return false if body.is_a?(Array)
154
+ return false if body.respond_to?(:to_ary)
155
+ return false if body.respond_to?(:to_str)
156
+
157
+ if defined?(Rack::BodyProxy) && body.is_a?(Rack::BodyProxy)
158
+ target = body.instance_variable_get(:@body)
159
+ if target
160
+ return false if target.is_a?(Array) || target.respond_to?(:to_ary) || target.respond_to?(:to_str)
161
+ end
162
+ end
163
+
164
+ body.respond_to?(:each)
165
+ end
166
+
167
+ def no_body_status?(status)
168
+ status < 200 || status == 204 || status == 205 || status == 304
169
+ end
170
+
171
+ def partial_content?(status, headers)
172
+ status == 206 || !get_header(headers, "content-range").nil?
173
+ end
174
+
175
+ def no_transform?(headers)
176
+ cache_control = get_header(headers, "cache-control")
177
+ cache_control && cache_control.downcase.include?("no-transform")
178
+ end
179
+
180
+ def already_encoded?(headers)
181
+ !get_header(headers, "content-encoding").nil?
182
+ end
183
+
184
+ def weaken_etag(headers)
185
+ etag = get_header(headers, "etag")
186
+ if etag && !etag.empty? && !etag.start_with?("W/")
187
+ set_header(headers, "etag", %(W/#{etag}))
188
+ end
189
+ end
190
+
191
+ def compressible_mime_type?(content_type)
192
+ return false if content_type.nil? || content_type.empty?
193
+
194
+ type = content_type.split(";").first.to_s.strip.downcase
195
+
196
+ return false if @exclude_mime_types.any? { |ex| type.start_with?(ex) || type.include?(ex) }
197
+
198
+ @mime_types.any? { |mime| type == mime || type.start_with?(mime) } ||
199
+ type.end_with?("+json") || type.end_with?("+xml")
200
+ end
201
+
202
+ def negotiate_encoder(accept_encoding)
203
+ return [nil, nil] if accept_encoding.strip.empty?
204
+
205
+ requested, disallowed = parse_accept_encoding(accept_encoding)
206
+
207
+ # 1. First check explicit requested encodings by q-value
208
+ requested.each do |enc, q_val|
209
+ next unless q_val > 0.0
210
+
211
+ if enc == "*"
212
+ # Wildcard: pick first available enabled encoding not explicitly disallowed
213
+ @enabled_encodings.each do |candidate|
214
+ next if disallowed.include?(candidate)
215
+ encoder_class = ENCODERS[candidate]
216
+ return [candidate, encoder_class] if encoder_class&.available?
217
+ end
218
+ elsif @enabled_encodings.include?(enc) && !disallowed.include?(enc)
219
+ encoder_class = ENCODERS[enc]
220
+ return [enc, encoder_class] if encoder_class&.available?
221
+ end
222
+ end
223
+
224
+ [nil, nil]
225
+ end
226
+
227
+ def parse_accept_encoding(header)
228
+ encodings = {}
229
+ disallowed = []
230
+
231
+ header.split(",").each do |part|
232
+ next if part.strip.empty?
233
+
234
+ enc, qval = part.split(";").map(&:strip)
235
+ next unless enc
236
+
237
+ q = 1.0
238
+ if qval && qval.start_with?("q=")
239
+ q = qval.sub("q=", "").to_f rescue 1.0
240
+ end
241
+
242
+ enc_down = enc.downcase
243
+ if q <= 0.0
244
+ disallowed << enc_down
245
+ else
246
+ encodings[enc_down] = q
247
+ end
248
+ end
249
+
250
+ [encodings.sort_by { |_, q| -q }.to_h, disallowed]
251
+ end
252
+
253
+ def extract_body_string(body)
254
+ buffer = String.new
255
+ if body.respond_to?(:each)
256
+ body.each { |part| buffer << part.to_s }
257
+ else
258
+ buffer << body.to_s
259
+ end
260
+ buffer
261
+ end
262
+
263
+ def get_header(headers, name)
264
+ return headers[name] if headers.key?(name)
265
+
266
+ down_name = name.downcase
267
+ headers.each do |key, value|
268
+ return value if key.to_s.downcase == down_name
269
+ end
270
+ nil
271
+ end
272
+
273
+ def set_header(headers, name, value)
274
+ matching_key = headers.keys.find { |k| k.to_s.downcase == name.downcase } || name
275
+ headers[matching_key] = value
276
+ end
277
+
278
+ def delete_header(headers, name)
279
+ matching_key = headers.keys.find { |k| k.to_s.downcase == name.downcase }
280
+ headers.delete(matching_key) if matching_key
281
+ end
282
+
283
+ def append_vary(headers, vary_val)
284
+ current = get_header(headers, "vary")
285
+ if current.nil? || current.empty?
286
+ set_header(headers, "vary", vary_val)
287
+ elsif !current.split(",").map(&:strip).include?(vary_val)
288
+ set_header(headers, "vary", "#{current}, #{vary_val}")
289
+ end
290
+ end
291
+ end
292
+ end
293
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module SmartCompress
5
+ class Railtie < ::Rails::Railtie
6
+ config.smart_compress = Configuration.new
7
+
8
+ initializer "rack_smart_compress.insert_middleware" do |app|
9
+ options = app.config.smart_compress.to_h
10
+
11
+ # If static assets enabled or public directory exists, insert Static before ActionDispatch::Static
12
+ if options[:static_assets]
13
+ static_options = {
14
+ root: options[:static_root] || app.paths["public"].first,
15
+ urls: options[:static_urls] || ["/"],
16
+ headers: options[:static_headers] || {},
17
+ cascade: options[:static_cascade] != false
18
+ }
19
+
20
+ if defined?(ActionDispatch::Static) && app.middleware.include?(ActionDispatch::Static)
21
+ app.middleware.insert_before ActionDispatch::Static, Rack::SmartCompress::Static, static_options
22
+ else
23
+ app.middleware.use Rack::SmartCompress::Static, static_options
24
+ end
25
+ end
26
+
27
+ if defined?(ActionDispatch::Static) && app.middleware.include?(ActionDispatch::Static)
28
+ app.middleware.insert_after ActionDispatch::Static, Rack::SmartCompress::Middleware, options
29
+ else
30
+ app.middleware.use Rack::SmartCompress::Middleware, options
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end if defined?(::Rails::Railtie)
@@ -0,0 +1,170 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/mime"
4
+ require "rack/utils"
5
+ require "time"
6
+
7
+ module Rack
8
+ module SmartCompress
9
+ class Static
10
+ DEFAULT_EXTENSIONS = {
11
+ "zstd" => ".zst",
12
+ "br" => ".br",
13
+ "gzip" => ".gz"
14
+ }.freeze
15
+
16
+ class FileBody
17
+ attr_reader :path
18
+
19
+ def initialize(path)
20
+ @path = path
21
+ end
22
+
23
+ def each
24
+ File.open(@path, "rb") do |file|
25
+ while (chunk = file.read(16_384))
26
+ yield chunk
27
+ end
28
+ end
29
+ end
30
+
31
+ def to_path
32
+ @path
33
+ end
34
+ end
35
+
36
+ attr_reader :app, :root, :urls, :encodings, :headers, :cascade
37
+
38
+ def initialize(app, options = {})
39
+ @app = app
40
+ @root = File.expand_path(options.fetch(:root, "public"))
41
+ @urls = Array(options.fetch(:urls, ["/"])).map(&:to_s)
42
+ @encodings = options.fetch(:encodings, DEFAULT_EXTENSIONS)
43
+ @headers = options.fetch(:headers, {})
44
+ @cascade = options.fetch(:cascade, true)
45
+ end
46
+
47
+ def call(env)
48
+ method = env["REQUEST_METHOD"]
49
+ return @app.call(env) unless %w[GET HEAD].include?(method)
50
+
51
+ path_info = env["PATH_INFO"].to_s
52
+ return @app.call(env) unless url_match?(path_info)
53
+
54
+ clean_path = Rack::Utils.clean_path_info(path_info)
55
+ full_path = File.join(@root, clean_path)
56
+
57
+ # Path traversal guard: ensure path is strictly inside @root
58
+ return @app.call(env) unless safe_path?(full_path)
59
+
60
+ accept_encoding = env["HTTP_ACCEPT_ENCODING"].to_s
61
+ encoding, compressed_path = find_precompressed_file(full_path, accept_encoding)
62
+
63
+ if compressed_path
64
+ serve_file(env, full_path, compressed_path, encoding, method)
65
+ elsif File.file?(full_path) && !@cascade
66
+ serve_file(env, full_path, full_path, nil, method)
67
+ else
68
+ @app.call(env)
69
+ end
70
+ end
71
+
72
+ private
73
+
74
+ def url_match?(path)
75
+ @urls.any? { |url| url == "/" || path.start_with?(url) }
76
+ end
77
+
78
+ def safe_path?(path)
79
+ expanded = File.expand_path(path)
80
+ expanded == @root || expanded.start_with?("#{@root}/")
81
+ end
82
+
83
+ def find_precompressed_file(base_path, accept_encoding)
84
+ return [nil, nil] if accept_encoding.strip.empty?
85
+
86
+ accepted = parse_accepted_encodings(accept_encoding)
87
+
88
+ accepted.each do |enc|
89
+ ext = @encodings[enc]
90
+ next unless ext
91
+
92
+ candidate_path = "#{base_path}#{ext}"
93
+ return [enc, candidate_path] if File.file?(candidate_path)
94
+ end
95
+
96
+ [nil, nil]
97
+ end
98
+
99
+ def parse_accepted_encodings(header)
100
+ encodings = {}
101
+ header.split(",").each do |part|
102
+ next if part.strip.empty?
103
+
104
+ enc, qval = part.split(";").map(&:strip)
105
+ next unless enc
106
+
107
+ q = 1.0
108
+ if qval && qval.start_with?("q=")
109
+ q = qval.sub("q=", "").to_f rescue 1.0
110
+ end
111
+
112
+ encodings[enc.downcase] = q if q > 0.0
113
+ end
114
+
115
+ encodings.sort_by { |_, q| -q }.map(&:first)
116
+ end
117
+
118
+ def serve_file(env, original_path, served_path, encoding, method)
119
+ stat = File.stat(served_path)
120
+ mtime = stat.mtime.httpdate
121
+ etag = %(W/"#{stat.mtime.to_i.to_s(16)}-#{stat.size.to_s(16)}")
122
+
123
+ # Conditional GET checks
124
+ if_none_match = env["HTTP_IF_NONE_MATCH"]
125
+ if_modified_since = env["HTTP_IF_MODIFIED_SINCE"]
126
+
127
+ if if_none_match && if_none_match == etag
128
+ return [304, build_headers(original_path, stat, encoding, mtime, etag), []]
129
+ end
130
+
131
+ if if_modified_since
132
+ begin
133
+ since_time = Time.httpdate(if_modified_since)
134
+ if stat.mtime <= since_time
135
+ return [304, build_headers(original_path, stat, encoding, mtime, etag), []]
136
+ end
137
+ rescue ArgumentError
138
+ # Invalid date format in header, proceed with full response
139
+ end
140
+ end
141
+
142
+ res_headers = build_headers(original_path, stat, encoding, mtime, etag)
143
+ body = method == "HEAD" ? [] : FileBody.new(served_path)
144
+
145
+ [200, res_headers, body]
146
+ end
147
+
148
+ def build_headers(original_path, stat, encoding, mtime, etag)
149
+ ext = File.extname(original_path)
150
+ mime_type = Rack::Mime.mime_type(ext, "application/octet-stream")
151
+
152
+ headers = {
153
+ "content-type" => mime_type,
154
+ "content-length" => stat.size.to_s,
155
+ "last-modified" => mtime,
156
+ "etag" => etag,
157
+ "vary" => "Accept-Encoding"
158
+ }
159
+
160
+ headers["content-encoding"] = encoding if encoding
161
+
162
+ @headers.each do |key, value|
163
+ headers[key.to_s.downcase] = value
164
+ end
165
+
166
+ headers
167
+ end
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "zlib"
5
+
6
+ module Rack
7
+ module SmartCompress
8
+ class StreamBody
9
+ attr_reader :body, :encoder_name, :encoder_class, :level
10
+
11
+ def initialize(body, encoder_name, encoder_class, level: nil)
12
+ @body = body
13
+ @encoder_name = encoder_name
14
+ @encoder_class = encoder_class
15
+ @level = level
16
+ @closed = false
17
+ end
18
+
19
+ def each(&block)
20
+ return enum_for(:each) unless block_given?
21
+
22
+ begin
23
+ case encoder_name
24
+ when "gzip"
25
+ stream_gzip(&block)
26
+ when "deflate"
27
+ stream_deflate(&block)
28
+ when "zstd"
29
+ stream_zstd(&block)
30
+ when "br"
31
+ stream_brotli(&block)
32
+ else
33
+ @body.each { |chunk| yield encoder_class.compress(chunk.to_s, level: level) }
34
+ end
35
+ ensure
36
+ close
37
+ end
38
+ end
39
+
40
+ def close
41
+ return if @closed
42
+
43
+ @closed = true
44
+ @body.close if @body.respond_to?(:close)
45
+ end
46
+
47
+ private
48
+
49
+ def stream_gzip
50
+ io = StringIO.new
51
+ io.set_encoding(Encoding::BINARY)
52
+ writer = Zlib::GzipWriter.new(io, level || Zlib::DEFAULT_COMPRESSION)
53
+
54
+ @body.each do |chunk|
55
+ chunk_str = chunk.to_s
56
+ next if chunk_str.empty?
57
+
58
+ writer.write(chunk_str)
59
+ writer.flush
60
+ data = io.string.dup
61
+ io.truncate(0)
62
+ io.rewind
63
+ yield data unless data.empty?
64
+ end
65
+
66
+ writer.close
67
+ final_data = io.string
68
+ yield final_data unless final_data.empty?
69
+ end
70
+
71
+ def stream_deflate
72
+ deflater = Zlib::Deflate.new(level || Zlib::DEFAULT_COMPRESSION)
73
+
74
+ @body.each do |chunk|
75
+ chunk_str = chunk.to_s
76
+ next if chunk_str.empty?
77
+
78
+ data = deflater.deflate(chunk_str, Zlib::SYNC_FLUSH)
79
+ yield data unless data.empty?
80
+ end
81
+
82
+ final_data = deflater.finish
83
+ yield final_data unless final_data.empty?
84
+ deflater.close
85
+ end
86
+
87
+ def stream_zstd
88
+ if defined?(::Zstd::StreamingCompress)
89
+ compressor = ::Zstd::StreamingCompress.new(level: level || 3)
90
+ @body.each do |chunk|
91
+ chunk_str = chunk.to_s
92
+ next if chunk_str.empty?
93
+
94
+ data = compressor.compress(chunk_str)
95
+ yield data unless data.nil? || data.empty?
96
+ end
97
+ final_data = compressor.finish
98
+ yield final_data unless final_data.nil? || final_data.empty?
99
+ else
100
+ buffer = String.new
101
+ @body.each { |chunk| buffer << chunk.to_s }
102
+ yield encoder_class.compress(buffer, level: level)
103
+ end
104
+ end
105
+
106
+ def stream_brotli
107
+ if defined?(::Brotli::Compressor)
108
+ compressor = ::Brotli::Compressor.new(quality: level || 4)
109
+ @body.each do |chunk|
110
+ chunk_str = chunk.to_s
111
+ next if chunk_str.empty?
112
+
113
+ data = compressor.process(chunk_str)
114
+ yield data unless data.nil? || data.empty?
115
+ end
116
+ final_data = compressor.finish
117
+ yield final_data unless final_data.nil? || final_data.empty?
118
+ else
119
+ buffer = String.new
120
+ @body.each { |chunk| buffer << chunk.to_s }
121
+ yield encoder_class.compress(buffer, level: level)
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module SmartCompress
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "smart_compress/version"
4
+ require_relative "smart_compress/configuration"
5
+ require_relative "smart_compress/middleware"
6
+ require_relative "smart_compress/static"
7
+ require_relative "smart_compress/railtie" if defined?(Rails)
8
+
9
+ module Rack
10
+ module SmartCompress
11
+ class << self
12
+ def new(app, options = {})
13
+ Middleware.new(app, options)
14
+ end
15
+
16
+ def configuration
17
+ @configuration ||= Configuration.new
18
+ end
19
+
20
+ def configure
21
+ yield(configuration) if block_given?
22
+ configuration
23
+ end
24
+
25
+ def reset_config!
26
+ @configuration = Configuration.new
27
+ end
28
+ end
29
+ end
30
+ end