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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 31b1ea77cbc0e6ff01ec184c4c99cc68c712ed24e94f8db1214f1cff24250ff8
4
+ data.tar.gz: b4fc25908f2e0b73872a7ca38a4276b41f75cc592a470f3144aa853063b2b8ca
5
+ SHA512:
6
+ metadata.gz: b7562181678286e9507ae01edbc8a01779c61ab0bfa97b94bde0768ddbf7d78bb8790090391b26355dae97ae4f3d25f4c0f61098367e7672ba2b552f303b85d2
7
+ data.tar.gz: a5ae38ec9123cc4d4350d55004c57128b89130254f1ccc9e831d0c61318065b131b55d22a95f42057bbedc4d5932b4afe489dc4d1c74f8a4369858d30160f3a3
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-08-16
9
+
10
+ ### Added
11
+ - Pre-Compressed Static Asset Middleware (`Rack::SmartCompress::Static`) supporting build-time `.zst`, `.br`, and `.gz` static files with 0% runtime CPU overhead.
12
+ - Configuration DSL via `Rack::SmartCompress.configure { |c| ... }` and `Rack::SmartCompress.configuration`.
13
+ - Dedicated Rails configuration hook via `config.smart_compress` namespace.
14
+ - RFC 9110 ยง8.8.1 ETag weakening (`W/"..."`) for dynamically compressed responses.
15
+ - RFC 9111 `Cache-Control: no-transform` support (skips compression when present).
16
+ - 206 Partial Content and `Content-Range` skipping to protect byte-range requests.
17
+ - Wildcard `Accept-Encoding: *` and explicit `q=0` rejection handling.
18
+ - Real chunked streaming for Brotli (`Brotli::Compressor`).
19
+ - ActiveSupport / Custom callable telemetry instrumentation (`rack_smart_compress.compress`).
20
+ - GitHub Actions CI workflow supporting Ruby 3.0, 3.1, 3.2, 3.3, and 3.4.
21
+
22
+ ### Fixed
23
+ - Fixed Zstd streaming class typo (`Zstd::StreamingCompress` instead of non-existent `Zstd::StreamingCompressor`).
24
+ - Fixed `Rack::BodyProxy` streaming misclassification in Rails applications.
25
+ - Relieved LRU cache mutex contention by computing compression outside the lock.
26
+ - Optimized LRU cache key hashing to eliminate large memory allocations.
27
+ - Added 1-second load average sampling cooldown to prevent `/proc/loadavg` syscall overhead on high-frequency requests.
28
+
29
+ ## [0.1.0] - Initial Release
30
+ - Initial release with Zstandard, Brotli, Gzip, and Deflate compression support.
data/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # โšก `rack-smart_compress`
2
+
3
+ > High-performance **Zstandard (`zstd`)**, **Brotli (`br`)**, and **Gzip (`gzip`)** dynamic HTTP compression & pre-compressed static asset middleware for Rack and Ruby on Rails applications.
4
+
5
+ [![Gem Version](https://badge.fury.io/rb/rack-smart_compress.svg)](https://badge.fury.io/rb/rack-smart_compress)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ `Rack::SmartCompress` replaces traditional `Rack::Deflater` by automatically negotiating modern, hyper-efficient compression algorithms like **Zstd** and **Brotli** with fallback to Gzip. It applies smart payload thresholding, LRU caching, and content-type filtering to cut outgoing server bandwidth by up to **40%** with zero setup friction.
9
+
10
+ ---
11
+
12
+ ## ๐ŸฅŠ Why `Rack::SmartCompress` vs `Rack::Deflater`?
13
+
14
+ | Feature | `Rack::Deflater` | `rack-brotli` | `Rack::SmartCompress` โšก |
15
+ | :--- | :---: | :---: | :---: |
16
+ | **Zstandard (`zstd`) Support** | โŒ | โŒ | โœ… **Native** |
17
+ | **Brotli (`br`) Support** | โŒ | โœ… | โœ… **Native** |
18
+ | **Gzip & Deflate Fallback** | โœ… | โŒ | โœ… **Native** |
19
+ | **Chunked Stream Compression** | โš ๏ธ Buffers | โŒ | โœ… **True Chunk Streaming** |
20
+ | **Pre-Compressed Static Assets (`.zst`, `.br`, `.gz`)** | โŒ | โŒ | โœ… **0% CPU Overhead** |
21
+ | **RFC 9110 ETag Weakening (`W/"..."`)** | โš ๏ธ Partial | โŒ | โœ… **RFC Compliant** |
22
+ | **`Cache-Control: no-transform` Respect** | โŒ | โŒ | โœ… **RFC Compliant** |
23
+ | **206 Partial Content / Range Protection** | โš ๏ธ Buggy | โŒ | โœ… **Protected** |
24
+ | **In-Memory LRU Payload Cache** | โŒ | โŒ | โœ… **Thread-Safe LRU** |
25
+ | **Dynamic CPU Load Advisor** | โŒ | โŒ | โœ… **Automatic** |
26
+ | **Rails Railtie Auto-Inject** | โŒ | โŒ | โœ… **`config.smart_compress`** |
27
+ | **ActiveSupport Telemetry Instrumentation** | โŒ | โŒ | โœ… **Built-in** |
28
+
29
+ ---
30
+
31
+ ## โœจ Features
32
+
33
+ - ๐ŸŽ๏ธ **Next-Gen Encodings:** Native support for **Zstandard (`zstd`)**, **Brotli (`br`)**, **Gzip**, and **Deflate**.
34
+ - ๐Ÿ“ฆ **Pre-Compressed Static Assets:** `Rack::SmartCompress::Static` serves build-time `.zst`, `.br`, and `.gz` files with **0% runtime CPU overhead**.
35
+ - ๐ŸŒŠ **True Chunked Streaming:** Progressive, non-buffering stream compression for Zstandard (`Zstd::StreamingCompress`), Brotli (`Brotli::Compressor`), and Gzip.
36
+ - ๐Ÿง  **Smart Payload Filtering:** Automatically skips tiny payloads (< 1KB) where compression overhead increases latency without savings.
37
+ - ๐Ÿ›ก๏ธ **RFC 9110 & 9111 Compliant:** Automatic strong-to-weak ETag conversion (`W/"..."`), `Cache-Control: no-transform` respect, and 206 Partial Content / `Content-Range` protection.
38
+ - ๐Ÿ›‘ **Media & Binary Exclusion:** Skips already compressed assets (`images`, `audio`, `video`, `zip`, `pdf`).
39
+ - โšก **Auto-Negotiation:** Respects HTTP `Accept-Encoding` quality weights (`qvalue`) and `*` wildcard sent by modern browsers and HTTP clients.
40
+ - ๐Ÿ“ฆ **Rails Auto-Integration:** Includes a built-in Railtie with `config.smart_compress` support.
41
+ - ๐Ÿ“Š **Telemetry & Observability:** Emits `rack_smart_compress.compress` notifications via `ActiveSupport::Notifications` or custom callbacks.
42
+ - ๐Ÿ›ก๏ธ **Rack 2 & Rack 3 Compatible:** Fully compliant with modern Rack specifications.
43
+
44
+ ---
45
+
46
+ ## ๐Ÿ“ฆ Installation
47
+
48
+ Add this line to your application's `Gemfile`:
49
+
50
+ ```ruby
51
+ gem "rack-smart_compress"
52
+ ```
53
+
54
+ To enable native **Brotli** and **Zstandard** compression drivers, add their gems to your Gemfile:
55
+
56
+ ```ruby
57
+ gem "brotli", "~> 0.4" # Optional: enables Brotli (br) compression
58
+ gem "zstd-ruby", "~> 1.5" # Optional: enables Zstandard (zstd) compression
59
+ ```
60
+
61
+ Then execute:
62
+
63
+ ```bash
64
+ $ bundle install
65
+ ```
66
+
67
+ ---
68
+
69
+ ## ๐Ÿš€ Quick Start
70
+
71
+ ### Ruby on Rails Integration
72
+
73
+ In Rails, `Rack::SmartCompress` automatically attaches to your middleware stack via Railtie.
74
+
75
+ Configure options in `config/initializers/smart_compress.rb` or `config/environments/production.rb`:
76
+
77
+ ```ruby
78
+ Rails.application.configure do
79
+ config.smart_compress.min_size = 1024 # Only compress responses >= 1 KB
80
+ config.smart_compress.encodings = %w[zstd br gzip deflate]
81
+ config.smart_compress.cache = true # Enable in-memory LRU payload caching
82
+ config.smart_compress.cache_size = 200 # Cache up to 200 warm response payloads
83
+ config.smart_compress.dynamic_levels = true # Dynamically drop compression levels under high CPU load
84
+ config.smart_compress.if = ->(env, status, headers) { env["HTTP_X_NO_COMPRESS"].nil? }
85
+
86
+ # Optional: Enable pre-compressed static asset serving (.zst, .br, .gz)
87
+ config.smart_compress.static_assets = true
88
+ config.smart_compress.static_headers = { "Cache-Control" => "public, max-age=31536000, immutable" }
89
+ end
90
+ ```
91
+
92
+ ### Global Configuration (Sinatra / Hanami / Pure Rack)
93
+
94
+ ```ruby
95
+ # config.ru or boot.rb
96
+ require "rack/smart_compress"
97
+
98
+ Rack::SmartCompress.configure do |config|
99
+ config.min_size = 1024
100
+ config.cache = true
101
+ config.encodings = %w[zstd br gzip]
102
+ end
103
+
104
+ use Rack::SmartCompress::Middleware
105
+ run YourRackApp.new
106
+ ```
107
+
108
+ ### Serving Pre-Compressed Static Assets (`.zst`, `.br`, `.gz`)
109
+
110
+ If your asset pipeline (Vite Ruby, Propshaft, Webpacker, esbuild) pre-compresses static assets into `.zst`, `.br`, and `.gz` files:
111
+
112
+ ```ruby
113
+ # config.ru
114
+ use Rack::SmartCompress::Static,
115
+ root: "public",
116
+ urls: ["/assets", "/packs"],
117
+ headers: { "Cache-Control" => "public, max-age=31536000, immutable" }
118
+
119
+ run YourRackApp.new
120
+ ```
121
+
122
+ When a browser requests `/assets/bundle.js` with `Accept-Encoding: zstd, br, gzip`, `Rack::SmartCompress::Static` will:
123
+ 1. Detect `bundle.js.zst` on disk.
124
+ 2. Serve it directly via streaming IO with **0% runtime CPU usage**.
125
+ 3. Set `Content-Type: application/javascript`, `Content-Encoding: zstd`, and `Vary: Accept-Encoding`.
126
+
127
+ ---
128
+
129
+ ## โš™๏ธ Configuration Options
130
+
131
+ | Option | Default | Description |
132
+ | :--- | :--- | :--- |
133
+ | `min_size` | `1024` (1 KB) | Minimum response body size in bytes required to trigger compression. |
134
+ | `encodings` | `%w[zstd br gzip deflate]` | Priority order of compression algorithms to allow. |
135
+ | `cache` | `false` | Enable thread-safe in-memory LRU payload caching for warm endpoints. |
136
+ | `cache_size` | `200` | Maximum number of compressed payloads to cache in memory. |
137
+ | `dynamic_levels` | `false` | Automatically drops compression levels under high CPU load to preserve low latency. |
138
+ | `if` | `nil` | Proc/Lambda `->(env, status, headers)` returning true to compress. |
139
+ | `unless` | `nil` | Proc/Lambda `->(env, status, headers)` returning true to skip compression. |
140
+ | `mime_types` | `[text/*, application/json, ...]` | List of compressible MIME types. Supports `+json` and `+xml` suffixes. |
141
+ | `exclude_mime_types` | `[image/*, video/*, audio/*, zip, pdf]` | List of MIME type prefixes to explicitly skip. |
142
+ | `zstd_level` | `3` | Zstandard compression level (1-22). |
143
+ | `brotli_level` | `4` | Brotli compression quality level (0-11). |
144
+ | `instrumentation` | `true` | Emits `rack_smart_compress.compress` via `ActiveSupport::Notifications` or block callback. |
145
+ | `static_assets` | `false` | Enables pre-compressed static asset serving in Rails. |
146
+ | `static_root` | `"public"` | Root directory for static asset resolution. |
147
+ | `static_urls` | `["/"]` | URL prefixes intercepted by static pre-compressed asset middleware. |
148
+
149
+ ---
150
+
151
+ ## ๐Ÿ“Š Benchmark & Performance
152
+
153
+ Run the built-in benchmark script to compare performance against your payloads:
154
+
155
+ ```bash
156
+ $ bundle exec rake benchmark
157
+ ```
158
+
159
+ **Sample Benchmark Output (108 KB JSON API payload):**
160
+
161
+ ```
162
+ ============================================================
163
+ Rack::SmartCompress Performance Benchmark
164
+ ============================================================
165
+ Algorithm | Compressed Size | Compression % | Avg Speed (ms)
166
+ ------------------------------------------------------------
167
+ Zstd | 3.07 KB | 97.16% | 0.085 ms
168
+ Brotli | 2.66 KB | 97.54% | 0.355 ms
169
+ Gzip | 8.16 KB | 92.45% | 0.498 ms
170
+ Deflate | 8.15 KB | 92.46% | 0.478 ms
171
+ ============================================================
172
+ ```
173
+
174
+ - **Zstandard (`zstd`)** delivers the fastest compression/decompression throughput (sub-0.1ms).
175
+ - **Brotli (`br`)** delivers the smallest payload size over the wire.
176
+
177
+ ---
178
+
179
+ ## ๐Ÿงช Running Tests
180
+
181
+ Run all unit and end-to-end integration tests:
182
+
183
+ ```bash
184
+ $ bundle exec rake
185
+ ```
186
+
187
+ Or individual suites:
188
+
189
+ ```bash
190
+ $ bundle exec rspec # RSpec unit tests (45 examples)
191
+ $ bundle exec rake e2e # Comprehensive End-to-End integration suite (18 tests)
192
+ ```
193
+
194
+ ---
195
+
196
+ ## ๐Ÿ“„ License
197
+
198
+ This gem is available as open source under the terms of the [MIT License](LICENSE.txt).
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Rack
6
+ module SmartCompress
7
+ class Configuration
8
+ DEFAULT_MIN_SIZE = 1024 # 1 KB
9
+
10
+ DEFAULT_ENCODINGS = %w[zstd br gzip deflate].freeze
11
+
12
+ DEFAULT_MIME_TYPES = [
13
+ "text/html",
14
+ "text/plain",
15
+ "text/css",
16
+ "text/javascript",
17
+ "text/xml",
18
+ "application/json",
19
+ "application/javascript",
20
+ "application/x-javascript",
21
+ "application/xml",
22
+ "application/xhtml+xml",
23
+ "application/rss+xml",
24
+ "application/atom+xml",
25
+ "application/wasm",
26
+ "image/svg+xml"
27
+ ].freeze
28
+
29
+ DEFAULT_EXCLUDED_MIME_TYPES = [
30
+ "image/jpeg",
31
+ "image/png",
32
+ "image/gif",
33
+ "image/webp",
34
+ "image/avif",
35
+ "audio/",
36
+ "video/",
37
+ "application/zip",
38
+ "application/x-gzip",
39
+ "application/pdf"
40
+ ].freeze
41
+
42
+ attr_accessor :min_size,
43
+ :encodings,
44
+ :mime_types,
45
+ :exclude_mime_types,
46
+ :cache,
47
+ :cache_size,
48
+ :dynamic_levels,
49
+ :zstd_level,
50
+ :brotli_level,
51
+ :gzip_level,
52
+ :deflate_level,
53
+ :if_condition,
54
+ :unless_condition,
55
+ :instrumentation,
56
+ :static_assets,
57
+ :static_root,
58
+ :static_urls,
59
+ :static_headers,
60
+ :static_cascade
61
+
62
+ def initialize
63
+ @min_size = DEFAULT_MIN_SIZE
64
+ @encodings = DEFAULT_ENCODINGS.dup
65
+ @mime_types = DEFAULT_MIME_TYPES.dup
66
+ @exclude_mime_types = DEFAULT_EXCLUDED_MIME_TYPES.dup
67
+ @cache = false
68
+ @cache_size = 200
69
+ @dynamic_levels = false
70
+ @zstd_level = 3
71
+ @brotli_level = 4
72
+ @gzip_level = Zlib::DEFAULT_COMPRESSION
73
+ @deflate_level = Zlib::DEFAULT_COMPRESSION
74
+ @if_condition = nil
75
+ @unless_condition = nil
76
+ @instrumentation = true
77
+ @static_assets = false
78
+ @static_root = "public"
79
+ @static_urls = ["/"]
80
+ @static_headers = {}
81
+ @static_cascade = true
82
+ end
83
+
84
+ # Convenience aliases for :if and :unless
85
+ def if(&block)
86
+ if block_given?
87
+ @if_condition = block
88
+ else
89
+ @if_condition
90
+ end
91
+ end
92
+
93
+ def if=(val)
94
+ @if_condition = val
95
+ end
96
+
97
+ def unless(&block)
98
+ if block_given?
99
+ @unless_condition = block
100
+ else
101
+ @unless_condition
102
+ end
103
+ end
104
+
105
+ def unless=(val)
106
+ @unless_condition = val
107
+ end
108
+
109
+ def to_h
110
+ {
111
+ min_size: @min_size,
112
+ encodings: @encodings,
113
+ mime_types: @mime_types,
114
+ exclude_mime_types: @exclude_mime_types,
115
+ cache: @cache,
116
+ cache_size: @cache_size,
117
+ dynamic_levels: @dynamic_levels,
118
+ zstd_level: @zstd_level,
119
+ brotli_level: @brotli_level,
120
+ gzip_level: @gzip_level,
121
+ deflate_level: @deflate_level,
122
+ if: @if_condition,
123
+ unless: @unless_condition,
124
+ instrumentation: @instrumentation,
125
+ static_assets: @static_assets,
126
+ static_root: @static_root,
127
+ static_urls: @static_urls,
128
+ static_headers: @static_headers,
129
+ static_cascade: @static_cascade
130
+ }
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "zlib"
5
+
6
+ module Rack
7
+ module SmartCompress
8
+ class CpuAdvisor
9
+ SAMPLE_INTERVAL = 1.0 # Sample at most once per second
10
+
11
+ class << self
12
+ def high_load?
13
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
14
+ if @last_check.nil? || (now - @last_check) >= SAMPLE_INTERVAL
15
+ @last_check = now
16
+ @cached_high_load = compute_high_load
17
+ end
18
+ @cached_high_load
19
+ end
20
+
21
+ def ncpu_count
22
+ @ncpu_count ||= begin
23
+ Etc.nprocessors
24
+ rescue StandardError
25
+ 2
26
+ end
27
+ end
28
+
29
+ def adjusted_level(encoder_name, default_level)
30
+ return default_level unless high_load?
31
+
32
+ case encoder_name
33
+ when "zstd"
34
+ 1 # Fastest Zstd level
35
+ when "br"
36
+ 1 # Fastest Brotli quality level
37
+ when "gzip", "deflate"
38
+ Zlib::BEST_SPEED # Fastest Gzip level (1)
39
+ else
40
+ default_level
41
+ end
42
+ end
43
+
44
+ def reset!
45
+ @last_check = nil
46
+ @cached_high_load = nil
47
+ @ncpu_count = nil
48
+ end
49
+
50
+ private
51
+
52
+ def compute_high_load
53
+ load_1min = current_load_avg
54
+ return false if load_1min.nil?
55
+
56
+ ncpu = ncpu_count
57
+ load_1min > (ncpu * 0.85) # High load threshold (> 85% capacity)
58
+ rescue StandardError
59
+ false
60
+ end
61
+
62
+ def current_load_avg
63
+ if File.exist?("/proc/loadavg")
64
+ File.read("/proc/loadavg").split.first.to_f
65
+ else
66
+ nil
67
+ end
68
+ rescue StandardError
69
+ nil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rack
4
+ module SmartCompress
5
+ module Encoders
6
+ class Base
7
+ class << self
8
+ def available?
9
+ true
10
+ end
11
+
12
+ def compress(body, level: nil)
13
+ raise NotImplementedError, "#{name}.compress must be implemented"
14
+ end
15
+
16
+ def encode_body(body, level: nil)
17
+ compressed = []
18
+ if body.respond_to?(:each)
19
+ buffer = String.new
20
+ body.each { |chunk| buffer << chunk.to_s }
21
+ compressed << compress(buffer, level: level)
22
+ else
23
+ compressed << compress(body.to_s, level: level)
24
+ end
25
+ compressed
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Rack
6
+ module SmartCompress
7
+ module Encoders
8
+ class Brotli < Base
9
+ ENCODING_NAME = "br"
10
+
11
+ class << self
12
+ def available?
13
+ return @available if defined?(@available)
14
+
15
+ @available = begin
16
+ require "brotli"
17
+ true
18
+ rescue LoadError
19
+ false
20
+ end
21
+ end
22
+
23
+ def compress(content, level: nil)
24
+ raise LoadError, "brotli gem is not available" unless available?
25
+
26
+ compression_level = level || 4
27
+ ::Brotli.deflate(content, quality: compression_level)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require_relative "base"
5
+
6
+ module Rack
7
+ module SmartCompress
8
+ module Encoders
9
+ class Deflate < Base
10
+ ENCODING_NAME = "deflate"
11
+
12
+ class << self
13
+ def available?
14
+ true
15
+ end
16
+
17
+ def compress(content, level: nil)
18
+ compression_level = level || Zlib::DEFAULT_COMPRESSION
19
+ Zlib::Deflate.deflate(content, compression_level)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+ require "stringio"
5
+ require_relative "base"
6
+
7
+ module Rack
8
+ module SmartCompress
9
+ module Encoders
10
+ class Gzip < Base
11
+ ENCODING_NAME = "gzip"
12
+
13
+ class << self
14
+ def available?
15
+ true
16
+ end
17
+
18
+ def compress(content, level: nil)
19
+ compression_level = level || Zlib::DEFAULT_COMPRESSION
20
+ io = StringIO.new
21
+ io.set_encoding(Encoding::BINARY)
22
+ writer = Zlib::GzipWriter.new(io, compression_level)
23
+ writer.write(content)
24
+ writer.close
25
+ io.string
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module Rack
6
+ module SmartCompress
7
+ module Encoders
8
+ class Zstd < Base
9
+ ENCODING_NAME = "zstd"
10
+
11
+ class << self
12
+ def available?
13
+ return @available if defined?(@available)
14
+
15
+ @available = begin
16
+ require "zstd-ruby"
17
+ true
18
+ rescue LoadError
19
+ false
20
+ end
21
+ end
22
+
23
+ def compress(content, level: nil)
24
+ raise LoadError, "zstd-ruby gem is not available" unless available?
25
+
26
+ compression_level = level || 3
27
+ ::Zstd.compress(content, level: compression_level)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "monitor"
5
+
6
+ module Rack
7
+ module SmartCompress
8
+ class LruCache
9
+ include MonitorMixin
10
+
11
+ DEFAULT_MAX_SIZE = 200
12
+
13
+ attr_reader :max_size, :hits, :misses
14
+
15
+ def initialize(max_size = DEFAULT_MAX_SIZE)
16
+ super()
17
+ @max_size = max_size
18
+ @cache = {}
19
+ @hits = 0
20
+ @misses = 0
21
+ end
22
+
23
+ def get(key)
24
+ mon_synchronize do
25
+ if @cache.key?(key)
26
+ @hits += 1
27
+ value = @cache.delete(key)
28
+ @cache[key] = value # Move to end (MRU)
29
+ value
30
+ else
31
+ @misses += 1
32
+ nil
33
+ end
34
+ end
35
+ end
36
+
37
+ def fetch(key)
38
+ cached = get(key)
39
+ return cached unless cached.nil?
40
+ return nil unless block_given?
41
+
42
+ # Execute expensive compression outside the mutex lock
43
+ value = yield
44
+ put(key, value)
45
+ value
46
+ end
47
+
48
+ def put(key, value)
49
+ mon_synchronize do
50
+ @cache.delete(key)
51
+ @cache[key] = value
52
+
53
+ if @cache.size > @max_size
54
+ @cache.shift # Remove oldest (LRU)
55
+ end
56
+ value
57
+ end
58
+ end
59
+
60
+ def delete(key)
61
+ mon_synchronize { @cache.delete(key) }
62
+ end
63
+
64
+ def clear
65
+ mon_synchronize do
66
+ @cache.clear
67
+ @hits = 0
68
+ @misses = 0
69
+ end
70
+ end
71
+
72
+ def size
73
+ mon_synchronize { @cache.size }
74
+ end
75
+
76
+ def build_key(encoder_name, level, content)
77
+ digest = Digest::SHA256.new
78
+ digest.update(encoder_name.to_s)
79
+ digest.update(":")
80
+ digest.update(level.to_s)
81
+ digest.update(":")
82
+ digest.update(content.to_s)
83
+ digest.hexdigest
84
+ end
85
+ end
86
+ end
87
+ end