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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +30 -0
- data/README.md +198 -0
- data/lib/rack/smart_compress/configuration.rb +134 -0
- data/lib/rack/smart_compress/cpu_advisor.rb +74 -0
- data/lib/rack/smart_compress/encoders/base.rb +31 -0
- data/lib/rack/smart_compress/encoders/brotli.rb +33 -0
- data/lib/rack/smart_compress/encoders/deflate.rb +25 -0
- data/lib/rack/smart_compress/encoders/gzip.rb +31 -0
- data/lib/rack/smart_compress/encoders/zstd.rb +33 -0
- data/lib/rack/smart_compress/lru_cache.rb +87 -0
- data/lib/rack/smart_compress/middleware.rb +293 -0
- data/lib/rack/smart_compress/railtie.rb +35 -0
- data/lib/rack/smart_compress/static.rb +170 -0
- data/lib/rack/smart_compress/stream_body.rb +126 -0
- data/lib/rack/smart_compress/version.rb +7 -0
- data/lib/rack/smart_compress.rb +30 -0
- metadata +158 -0
|
@@ -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,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
|