diff --git a/Gemfile b/Gemfile index 90c02e2..16736fe 100644 --- a/Gemfile +++ b/Gemfile @@ -17,4 +17,6 @@ group :test do gem 'async' # Puma to host test server gem 'puma' + # Brotli for compression tests + gem 'brotli' end diff --git a/Gemfile.lock b/Gemfile.lock index b3d2941..2503ed1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,6 +15,7 @@ GEM io-event (~> 1.11) metrics (~> 0.12) traces (~> 0.18) + brotli (0.8.0) console (1.34.2) fiber-annotation fiber-local (~> 1.1) @@ -77,6 +78,7 @@ PLATFORMS DEPENDENCIES async + brotli datastar! debug logger diff --git a/README.md b/README.md index 23a5b5e..73cee12 100644 --- a/README.md +++ b/README.md @@ -270,13 +270,105 @@ Datastar.configure do |config| config.on_error do |exception| Sentry.notify(exception) end - + # Global heartbeat interval (or false, to disable) - # Can be overriden on specific instances + # Can be overriden on specific instances config.heartbeat = 0.3 + + # Enable compression for SSE streams (default: false) + # See the Compression section below for details + config.compression = true +end +``` + +### Compression + +SSE data (JSON + HTML) is highly compressible, and long-lived connections benefit significantly from compression. This SDK supports opt-in Brotli and gzip compression for SSE streams. + +#### Enabling compression + +Per-instance: + +```ruby +datastar = Datastar.new(request:, response:, view_context:, compression: true) +``` + +Or globally: + +```ruby +Datastar.configure do |config| + config.compression = true +end +``` + +When enabled, the SDK negotiates compression with the client via the `Accept-Encoding` header and sets the appropriate `Content-Encoding` response header. If the client does not support compression, responses are sent uncompressed. + +#### Brotli vs gzip + +Brotli (`:br`) is preferred by default as it offers better compression ratios. It requires the host app to require the [`brotli`](https://github.com/miyucy/brotli) gem. Gzip uses Ruby built-in `zlib` and requires no extra dependencies. + +To use Brotli, add the gem to your `Gemfile`: + +```ruby +gem 'brotli' +``` + +#### Configuration options + +```ruby +Datastar.configure do |config| + # Enable compression (default: false) + # true enables both :br and :gzip (br preferred) + config.compression = true + + # Or pass an array of encodings (first = preferred) + config.compression = [:br, :gzip] + + # Per-encoder options via [symbol, options] pairs + config.compression = [[:br, { quality: 5 }], :gzip] end ``` +You can also set these per-instance: + +```ruby +datastar = Datastar.new( + request:, response:, view_context:, + compression: [:gzip] # only gzip, no brotli +) + +# Or with per-encoder options +datastar = Datastar.new( + request:, response:, view_context:, + compression: [[:gzip, { level: 1 }]] +) +``` + +#### Per-encoder options + +Options are passed directly to the underlying compressor via the array form. Available options depend on the encoder. + +**Gzip** (via `Zlib::Deflate`): + +| Option | Default | Description | +| ------------ | --------------------------- | ------------------------------------------------------------ | +| `:level` | `Zlib::DEFAULT_COMPRESSION` | Compression level (0-9). 0 = none, 1 = fastest, 9 = smallest. `Zlib::BEST_SPEED` and `Zlib::BEST_COMPRESSION` also work. | +| `:mem_level` | `8` | Memory usage (1-9). Higher uses more memory for better compression. | +| `:strategy` | `Zlib::DEFAULT_STRATEGY` | Algorithm strategy. Alternatives: `Zlib::FILTERED`, `Zlib::HUFFMAN_ONLY`, `Zlib::RLE`, `Zlib::FIXED`. | + +**Brotli** (via `Brotli::Compressor`, requires the `brotli` gem): + +| Option | Default | Description | +| ---------- | ---------- | ------------------------------------------------------------ | +| `:quality` | `11` | Compression quality (0-11). Lower is faster, higher compresses better. | +| `:lgwin` | `22` | Base-2 log of sliding window size (10-24). | +| `:lgblock` | `0` (auto) | Base-2 log of max input block size (16-24, or 0 for auto). | +| `:mode` | `:generic` | Compression mode: `:generic`, `:text`, or `:font`. `:text` is a good choice for SSE (UTF-8 HTML/JSON). | + +#### Proxy considerations + +Even with `X-Accel-Buffering: no` (set by default), some proxies like Nginx may buffer compressed responses. You may need to add `proxy_buffering off` to your Nginx configuration when using compression with SSE. + ### Rendering Rails templates In Rails, make sure to initialize Datastar with the `view_context` in a controller. diff --git a/benchmarks/compression.rb b/benchmarks/compression.rb new file mode 100644 index 0000000..3966b94 --- /dev/null +++ b/benchmarks/compression.rb @@ -0,0 +1,251 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Benchmark: SSE compression payload sizes +# +# Compares bytes-over-the-wire for no compression, gzip, and brotli +# when streaming large HTML elements via Datastar's SSE protocol. +# +# Usage: +# bundle exec ruby benchmarks/compression.rb +# +# The benchmark patches realistic HTML payloads of increasing size +# through the full Datastar SSE pipeline (ServerSentEventGenerator → +# CompressedSocket → raw socket) and reports the resulting byte sizes +# and compression ratios. + +require 'bundler/setup' +require 'datastar' +require 'datastar/compressor/gzip' +require 'datastar/compressor/brotli' + +# --- Payload generators --------------------------------------------------- + +# A user-row partial, repeated N times inside a . +# Realistic: IDs, data attributes, mixed text, Tailwind-style classes. +def html_table(row_count) + rows = row_count.times.map do |i| + <<~HTML + + #{i + 1} + user-#{i}@example.com + #{%w[Admin Editor Viewer].sample} + 2025-01-#{(i % 28 + 1).to_s.rjust(2, '0')} + + + + + + HTML + end + + <<~HTML + + #{rows.join} + + HTML +end + +# A dashboard card with nested elements — charts placeholder, stats, lists. +def html_dashboard(card_count) + cards = card_count.times.map do |i| + <<~HTML +
+
+

Metric #{i + 1}

+ +#{rand(1..99)}% +
+
#{rand(1_000..99_999).to_s.chars.each_slice(3).map(&:join).join(',')}
+
+ #{8.times.map { |j| "
" }.join("\n ")} +
+ +
+ HTML + end + + <<~HTML +
+ #{cards.join} +
+ HTML +end + +# --- Socket that counts bytes -------------------------------------------- + +class ByteCountingSocket + attr_reader :total_bytes + + def initialize + @total_bytes = 0 + end + + def <<(data) + @total_bytes += data.bytesize + self + end + + def close; end +end + +# --- Helpers -------------------------------------------------------------- + +# Pipe an HTML payload through the full SSE + compression stack +# and return the byte count that would go over the wire. +def measure_bytes(html, encoding) + socket = ByteCountingSocket.new + + wrapped = case encoding + when :none then socket + when :gzip then Datastar::Compressor::Gzip::CompressedSocket.new(socket) + when :br then Datastar::Compressor::Brotli::CompressedSocket.new(socket, mode: :text) + end + + generator = Datastar::ServerSentEventGenerator.new( + wrapped, + signals: {}, + view_context: nil + ) + + generator.patch_elements(html) + wrapped.close unless encoding == :none + + socket.total_bytes +end + +def format_bytes(bytes) + if bytes >= 1024 * 1024 + "%.1f MB" % (bytes / (1024.0 * 1024)) + elsif bytes >= 1024 + "%.1f KB" % (bytes / 1024.0) + else + "#{bytes} B" + end +end + +def ratio(original, compressed) + "%.1f%%" % ((1.0 - compressed.to_f / original) * 100) +end + +# --- Run benchmarks ------------------------------------------------------- + +SCENARIOS = [ + ["Table 10 rows", -> { html_table(10) }], + ["Table 50 rows", -> { html_table(50) }], + ["Table 200 rows", -> { html_table(200) }], + ["Table 1000 rows", -> { html_table(1000) }], + ["Dashboard 5 cards", -> { html_dashboard(5) }], + ["Dashboard 20 cards",-> { html_dashboard(20) }], + ["Dashboard 50 cards",-> { html_dashboard(50) }], +] + +ENCODINGS = %i[none gzip br] + +# Header +puts "Datastar SSE Compression Benchmark" +puts "=" * 90 +puts +puts format( + "%-22s %12s %12s %8s %12s %8s", + "Scenario", "No Compress", "Gzip", "Saved", "Brotli", "Saved" +) +puts "-" * 90 + +SCENARIOS.each do |name, generator| + html = generator.call + results = ENCODINGS.map { |enc| [enc, measure_bytes(html, enc)] }.to_h + none = results[:none] + + puts format( + "%-22s %12s %12s %8s %12s %8s", + name, + format_bytes(none), + format_bytes(results[:gzip]), + ratio(none, results[:gzip]), + format_bytes(results[:br]), + ratio(none, results[:br]) + ) +end + +# --- Streaming: multiple SSE events over one connection ------------------- + +# Simulates a long-lived SSE connection where rows are patched individually +# (e.g. a live-updating table). The compressor stays open across events, +# so repeated structure (CSS classes, attribute patterns) compresses +# increasingly well as the dictionary builds up. + +def measure_streaming_bytes(payloads, encoding) + socket = ByteCountingSocket.new + + wrapped = case encoding + when :none then socket + when :gzip then Datastar::Compressor::Gzip::CompressedSocket.new(socket) + when :br then Datastar::Compressor::Brotli::CompressedSocket.new(socket, mode: :text) + end + + generator = Datastar::ServerSentEventGenerator.new( + wrapped, + signals: {}, + view_context: nil + ) + + payloads.each { |html| generator.patch_elements(html) } + wrapped.close unless encoding == :none + + socket.total_bytes +end + +def table_rows(count) + count.times.map do |i| + <<~HTML + + #{i + 1} + user-#{i}@example.com + #{%w[Admin Editor Viewer].sample} + 2025-01-#{(i % 28 + 1).to_s.rjust(2, '0')} + + + + + + HTML + end +end + +puts +puts +puts "Streaming: individual row patches over one SSE connection" +puts "=" * 90 +puts +puts format( + "%-22s %12s %12s %8s %12s %8s", + "Scenario", "No Compress", "Gzip", "Saved", "Brotli", "Saved" +) +puts "-" * 90 + +[10, 50, 200, 1000].each do |count| + payloads = table_rows(count) + results = ENCODINGS.map { |enc| [enc, measure_streaming_bytes(payloads, enc)] }.to_h + none = results[:none] + + puts format( + "%-22s %12s %12s %8s %12s %8s", + "#{count} row patches", + format_bytes(none), + format_bytes(results[:gzip]), + ratio(none, results[:gzip]), + format_bytes(results[:br]), + ratio(none, results[:br]) + ) +end + +puts +puts "Notes:" +puts " - Single-event sizes include full SSE framing (event: / data: prefixes)" +puts " - Gzip: default compression level, gzip framing (window_bits=31)" +puts " - Brotli: default quality (11) with mode: :text" +puts " - Streaming rows: each row is a separate patch_elements SSE event" +puts " over one persistent compressed connection. The compressor dictionary" +puts " builds up across events, improving ratios for repetitive markup." diff --git a/lib/datastar.rb b/lib/datastar.rb index 9c1d293..e51db29 100644 --- a/lib/datastar.rb +++ b/lib/datastar.rb @@ -25,6 +25,7 @@ def self.from_rack_env(env, view_context: nil) end require_relative 'datastar/configuration' +require_relative 'datastar/compression_config' require_relative 'datastar/dispatcher' require_relative 'datastar/server_sent_event_generator' require_relative 'datastar/railtie' if defined?(Rails::Railtie) diff --git a/lib/datastar/compression_config.rb b/lib/datastar/compression_config.rb new file mode 100644 index 0000000..6eec7aa --- /dev/null +++ b/lib/datastar/compression_config.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require 'set' + +module Datastar + module Compressor + # Null compressor — no-op, used when compression is disabled or no match. + class Null + def encoding = nil + def wrap_socket(socket) = socket + def prepare_response(_response) = nil + end + + NONE = Null.new.freeze + end + + # Immutable value object that holds an ordered list of pre-built compressors + # and negotiates the best one for a given request. + # + # Use {.build} to create instances from user-facing configuration values. + # The first compressor in the list is preferred when the client supports multiple. + # + # @example Via global configuration + # Datastar.configure do |config| + # config.compression = true # [:br, :gzip] with default options + # config.compression = [:br, :gzip] # preferred = first in list + # config.compression = [[:br, { quality: 5 }], :gzip] # per-encoder options + # end + # + # @example Per-request negotiation (used internally by Dispatcher) + # compressor = Datastar.config.compression.negotiate(request) + # compressor.prepare_response(response) + # socket = compressor.wrap_socket(raw_socket) + class CompressionConfig + ACCEPT_ENCODING = 'HTTP_ACCEPT_ENCODING' + BLANK_HASH = {}.freeze + + # Build a {CompressionConfig} from various user-facing input forms. + # + # @param input [Boolean, Array, CompressionConfig] + # - +false+ / +nil+ — compression disabled (empty compressor list) + # - +true+ — enable +:br+ and +:gzip+ with default options + # - +Array+ — enable listed encodings with default options, e.g. +[:gzip]+ + # - +Array+ — enable with per-encoder options, + # e.g. +[[:br, { quality: 5 }], :gzip]+ + # - +CompressionConfig+ — returned as-is + # @return [CompressionConfig] + # @raise [ArgumentError] if +input+ is not a recognised form + # @raise [LoadError] if a requested encoder's gem is not available (e.g. +brotli+) + # + # @example Disable compression + # CompressionConfig.build(false) + # + # @example Enable all supported encodings + # CompressionConfig.build(true) + # + # @example Gzip only, with custom level + # CompressionConfig.build([[:gzip, { level: 1 }]]) + def self.build(input) + case input + when CompressionConfig + input + when false, nil + new([]) + when true + new([build_compressor(:br), build_compressor(:gzip)]) + when Array + compressors = input.map do |entry| + case entry + when Symbol + build_compressor(entry) + when Array + name, options = entry + build_compressor(name, options || BLANK_HASH) + else + raise ArgumentError, "Invalid compression entry: #{entry.inspect}. Expected Symbol or [Symbol, Hash]." + end + end + new(compressors) + else + raise ArgumentError, "Invalid compression value: #{input.inspect}. Expected true, false, or Array." + end + end + + def self.build_compressor(name, options = BLANK_HASH) + case name + when :br + require_relative 'compressor/brotli' + Compressor::Brotli.new(options) + when :gzip + require_relative 'compressor/gzip' + Compressor::Gzip.new(options) + else + raise ArgumentError, "Unknown compressor: #{name.inspect}. Expected :br or :gzip." + end + end + private_class_method :build_compressor + + # @param compressors [Array] + # ordered list of pre-built compressor instances. First = preferred. + def initialize(compressors) + @compressors = compressors.freeze + freeze + end + + # Whether any compressors are configured. + # + # @return [Boolean] + # + # @example + # CompressionConfig.build(false).enabled? # => false + # CompressionConfig.build(true).enabled? # => true + def enabled? + @compressors.any? + end + + # Negotiate compression with the client based on the +Accept-Encoding+ header. + # + # Iterates the configured compressors in order (first = preferred) and returns + # the first one whose encoding the client accepts. Returns {Compressor::NONE} + # when compression is disabled, the header is absent, or no match is found. + # + # No objects are created per-request — compressors are pre-built and reused. + # + # @param request [Rack::Request] + # @return [Compressor::Gzip, Compressor::Brotli, Compressor::Null] + # + # @example + # config = CompressionConfig.build([:gzip, :br]) + # compressor = config.negotiate(request) + # compressor.prepare_response(response) + # socket = compressor.wrap_socket(raw_socket) + def negotiate(request) + return Compressor::NONE unless enabled? + + accepted = parse_accept_encoding(request.get_header(ACCEPT_ENCODING).to_s) + return Compressor::NONE if accepted.empty? + + @compressors.each do |compressor| + return compressor if accepted.include?(compressor.encoding) + end + + Compressor::NONE + end + + private + + # Parse Accept-Encoding header into a set of encoding symbols + # @param header [String] + # @return [Set] + def parse_accept_encoding(header) + return Set.new if header.empty? + + encodings = Set.new + header.split(',').each do |part| + encoding, quality = part.strip.split(';', 2) + encoding = encoding.strip.downcase + if quality + q_val = quality.strip.match(/q=(\d+\.?\d*)/) + next if q_val && q_val[1].to_f == 0 + end + encodings << encoding.to_sym + end + encodings + end + end +end diff --git a/lib/datastar/compressor/brotli.rb b/lib/datastar/compressor/brotli.rb new file mode 100644 index 0000000..698bba3 --- /dev/null +++ b/lib/datastar/compressor/brotli.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'brotli' + +module Datastar + module Compressor + # Brotli compressor — built once at config time, reused across requests. + # Eagerly requires the brotli gem; raises LoadError at boot if missing. + class Brotli + attr_reader :encoding + + def initialize(options) + @options = options.freeze + @encoding = :br + freeze + end + + def prepare_response(response) + response.headers['Content-Encoding'] = 'br' + response.headers['Vary'] = 'Accept-Encoding' + end + + def wrap_socket(socket) + CompressedSocket.new(socket, @options) + end + + # Brotli compressed socket using the `brotli` gem. + # Options are passed directly to Brotli::Compressor.new: + # :quality - Compression quality (0-11, default: 11). Lower is faster, higher compresses better. + # :lgwin - Base-2 log of the sliding window size (10-24, default: 22). + # :lgblock - Base-2 log of the maximum input block size (16-24, 0 = auto, default: 0). + # :mode - Compression mode (:generic, :text, or :font, default: :generic). + # Use :text for UTF-8 formatted text (HTML, JSON — good for SSE). + class CompressedSocket + def initialize(socket, options = {}) + @socket = socket + @compressor = ::Brotli::Compressor.new(options) + end + + def <<(data) + compressed = @compressor.process(data) + @socket << compressed if compressed && !compressed.empty? + flushed = @compressor.flush + @socket << flushed if flushed && !flushed.empty? + self + end + + def close + final = @compressor.finish + @socket << final if final && !final.empty? + @socket.close + end + end + end + end +end diff --git a/lib/datastar/compressor/gzip.rb b/lib/datastar/compressor/gzip.rb new file mode 100644 index 0000000..21b3b6d --- /dev/null +++ b/lib/datastar/compressor/gzip.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'zlib' + +module Datastar + module Compressor + # Gzip compressor — built once at config time, reused across requests. + class Gzip + attr_reader :encoding + + def initialize(options) + @options = options.freeze + @encoding = :gzip + freeze + end + + def prepare_response(response) + response.headers['Content-Encoding'] = 'gzip' + response.headers['Vary'] = 'Accept-Encoding' + end + + def wrap_socket(socket) + CompressedSocket.new(socket, @options) + end + + # Gzip compressed socket using Ruby's built-in zlib. + # Options: + # :level - Compression level (0-9, default: Zlib::DEFAULT_COMPRESSION). + # 0 = no compression, 1 = best speed, 9 = best compression. + # Zlib::BEST_SPEED (1) and Zlib::BEST_COMPRESSION (9) also work. + # :mem_level - Memory usage level (1-9, default: 8). Higher uses more memory for better compression. + # :strategy - Compression strategy (default: Zlib::DEFAULT_STRATEGY). + # Zlib::FILTERED, Zlib::HUFFMAN_ONLY, Zlib::RLE, Zlib::FIXED are also available. + class CompressedSocket + def initialize(socket, options = {}) + level = options.fetch(:level, Zlib::DEFAULT_COMPRESSION) + mem_level = options.fetch(:mem_level, Zlib::DEF_MEM_LEVEL) + strategy = options.fetch(:strategy, Zlib::DEFAULT_STRATEGY) + # Use raw deflate with gzip wrapping (window_bits 31 = 15 + 16) + @socket = socket + @deflate = Zlib::Deflate.new(level, 31, mem_level, strategy) + end + + def <<(data) + compressed = @deflate.deflate(data, Zlib::SYNC_FLUSH) + @socket << compressed if compressed && !compressed.empty? + self + end + + def close + final = @deflate.finish + @socket << final if final && !final.empty? + @socket.close + ensure + @deflate.close + end + end + end + end +end diff --git a/lib/datastar/configuration.rb b/lib/datastar/configuration.rb index 3df339b..12cb0d4 100644 --- a/lib/datastar/configuration.rb +++ b/lib/datastar/configuration.rb @@ -35,6 +35,7 @@ class Configuration DEFAULT_HEARTBEAT = 3 attr_accessor :executor, :error_callback, :finalize, :heartbeat, :logger + attr_reader :compression def initialize @executor = ThreadExecutor.new @@ -44,6 +45,11 @@ def initialize @error_callback = proc do |e| @logger.error("#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}") end + @compression = CompressionConfig.build(false) + end + + def compression=(value) + @compression = value.is_a?(CompressionConfig) ? value : CompressionConfig.build(value) end def on_error(callable = nil, &block) diff --git a/lib/datastar/dispatcher.rb b/lib/datastar/dispatcher.rb index 98e4600..053ee96 100644 --- a/lib/datastar/dispatcher.rb +++ b/lib/datastar/dispatcher.rb @@ -44,7 +44,8 @@ def initialize( executor: Datastar.config.executor, error_callback: Datastar.config.error_callback, finalize: Datastar.config.finalize, - heartbeat: Datastar.config.heartbeat + heartbeat: Datastar.config.heartbeat, + compression: Datastar.config.compression ) @on_connect = [] @on_client_disconnect = [] @@ -68,6 +69,11 @@ def initialize( @heartbeat = heartbeat @heartbeat_on = false + + # Negotiate compression + compression = CompressionConfig.build(compression) unless compression.is_a?(CompressionConfig) + @compressor = compression.negotiate(request) + @compressor.prepare_response(@response) end # Check if the request accepts SSE responses @@ -283,6 +289,7 @@ def stream_no_heartbeat(&block) # @api private def stream_one(streamer) proc do |socket| + socket = wrap_socket(socket) generator = ServerSentEventGenerator.new(socket, signals:, view_context: @view_context) @on_connect.each { |callable| callable.call(generator) } handling_sync_errors(generator, socket) do @@ -308,6 +315,7 @@ def stream_many(streamer) @queue ||= @executor.new_queue proc do |socket| + socket = wrap_socket(socket) signs = signals conn_generator = ServerSentEventGenerator.new(socket, signals: signs, view_context: @view_context) @on_connect.each { |callable| callable.call(conn_generator) } @@ -360,6 +368,13 @@ def stream_many(streamer) end end + # Wrap socket in a CompressedSocket if compression is negotiated + # @param socket [IO] + # @return [CompressedSocket, IO] + def wrap_socket(socket) + @compressor.wrap_socket(socket) + end + # Handle errors caught during streaming # @param error [Exception] the error that occurred # @param socket [IO] the socket to pass to error handlers diff --git a/spec/compressed_socket_spec.rb b/spec/compressed_socket_spec.rb new file mode 100644 index 0000000..f208ab6 --- /dev/null +++ b/spec/compressed_socket_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require 'datastar' +require 'datastar/compressor/gzip' +require 'datastar/compressor/brotli' + +RSpec.describe 'Compressor compressed sockets' do + let(:raw_socket) { StringSocket.new } + let(:sse_data) { "event: datastar-patch-signals\ndata: signals {\"foo\":\"bar\"}\n\n" } + + # A simple socket that collects binary data + class StringSocket + attr_reader :closed + + def initialize + @buffer = String.new(encoding: Encoding::BINARY) + @closed = false + end + + def <<(data) + @buffer << data.b + self + end + + def close + @closed = true + end + + def bytes + @buffer + end + end + + describe Datastar::Compressor::Gzip::CompressedSocket do + subject(:socket) { described_class.new(raw_socket) } + + it 'compresses data and decompresses to original' do + socket << sse_data + socket.close + + decompressed = Zlib::Inflate.new(31).inflate(raw_socket.bytes) + expect(decompressed).to eq(sse_data) + end + + it 'flushes data after each write (data available before close)' do + socket << sse_data + # Data should be in raw_socket before close + expect(raw_socket.bytes).not_to be_empty + + partial = Zlib::Inflate.new(31).inflate(raw_socket.bytes) + expect(partial).to eq(sse_data) + end + + it 'handles multiple writes' do + data1 = "event: datastar-patch-signals\ndata: signals {\"a\":1}\n\n" + data2 = "event: datastar-patch-signals\ndata: signals {\"b\":2}\n\n" + + socket << data1 + socket << data2 + socket.close + + decompressed = Zlib::Inflate.new(31).inflate(raw_socket.bytes) + expect(decompressed).to eq(data1 + data2) + end + + it 'closes the underlying socket' do + socket << sse_data + socket.close + expect(raw_socket.closed).to be(true) + end + + it 'accepts compression level option' do + socket = described_class.new(raw_socket, level: Zlib::BEST_SPEED) + socket << sse_data + socket.close + + decompressed = Zlib::Inflate.new(31).inflate(raw_socket.bytes) + expect(decompressed).to eq(sse_data) + end + end + + describe Datastar::Compressor::Brotli::CompressedSocket do + subject(:socket) { described_class.new(raw_socket) } + + it 'compresses data and decompresses to original' do + socket << sse_data + socket.close + + decompressed = ::Brotli.inflate(raw_socket.bytes) + expect(decompressed).to eq(sse_data) + end + + it 'flushes data after each write (data available before close)' do + socket << sse_data + expect(raw_socket.bytes).not_to be_empty + end + + it 'handles multiple writes' do + data1 = "event: datastar-patch-signals\ndata: signals {\"a\":1}\n\n" + data2 = "event: datastar-patch-signals\ndata: signals {\"b\":2}\n\n" + + socket << data1 + socket << data2 + socket.close + + decompressed = ::Brotli.inflate(raw_socket.bytes) + expect(decompressed).to eq(data1 + data2) + end + + it 'closes the underlying socket' do + socket << sse_data + socket.close + expect(raw_socket.closed).to be(true) + end + end + +end diff --git a/spec/compression_config_spec.rb b/spec/compression_config_spec.rb new file mode 100644 index 0000000..a0e715d --- /dev/null +++ b/spec/compression_config_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require 'datastar' +require 'rack' + +RSpec.describe Datastar::CompressionConfig do + describe '.build' do + it 'returns a disabled config for false' do + config = described_class.build(false) + expect(config.enabled?).to be(false) + end + + it 'returns a disabled config for nil' do + config = described_class.build(nil) + expect(config.enabled?).to be(false) + end + + it 'returns an enabled config with br and gzip for true' do + config = described_class.build(true) + expect(config.enabled?).to be(true) + end + + it 'builds compressors from an array of symbols' do + config = described_class.build([:gzip]) + expect(config.enabled?).to be(true) + + request = build_request('Accept-Encoding' => 'gzip') + compressor = config.negotiate(request) + expect(compressor.encoding).to eq(:gzip) + end + + it 'builds compressors from nested array with options' do + config = described_class.build([[:gzip, { level: 1 }]]) + expect(config.enabled?).to be(true) + + request = build_request('Accept-Encoding' => 'gzip') + compressor = config.negotiate(request) + expect(compressor.encoding).to eq(:gzip) + end + + it 'builds mixed array of symbols and [symbol, hash] pairs' do + config = described_class.build([[:br, { quality: 5 }], :gzip]) + expect(config.enabled?).to be(true) + end + + it 'returns the input if already a CompressionConfig' do + original = described_class.build(true) + expect(described_class.build(original)).to equal(original) + end + + it 'raises ArgumentError for invalid input' do + expect { described_class.build('invalid') }.to raise_error(ArgumentError) + end + + it 'raises ArgumentError for unknown compressor symbol' do + expect { described_class.build([:deflate]) }.to raise_error(ArgumentError, /Unknown compressor/) + end + + it 'raises LoadError at build time if brotli compressor file cannot be loaded' do + allow(described_class).to receive(:build_compressor).and_call_original + allow(described_class).to receive(:build_compressor).with(:br).and_raise(LoadError) + expect { described_class.build([:br]) }.to raise_error(LoadError) + end + end + + describe '#negotiate' do + it 'returns Null compressor when disabled' do + config = described_class.build(false) + request = build_request('Accept-Encoding' => 'br, gzip') + compressor = config.negotiate(request) + expect(compressor).to be_a(Datastar::Compressor::Null) + expect(compressor.encoding).to be_nil + end + + it 'returns Null compressor when no Accept-Encoding header' do + config = described_class.build(true) + request = build_request + compressor = config.negotiate(request) + expect(compressor).to be_a(Datastar::Compressor::Null) + end + + it 'returns gzip compressor when client accepts gzip' do + config = described_class.build([:gzip]) + request = build_request('Accept-Encoding' => 'gzip') + compressor = config.negotiate(request) + expect(compressor.encoding).to eq(:gzip) + end + + it 'returns first compressor (preferred) when client supports both' do + config = described_class.build([:br, :gzip]) + request = build_request('Accept-Encoding' => 'br, gzip') + compressor = config.negotiate(request) + expect(compressor.encoding).to eq(:br) + end + + it 'respects list order for preference' do + config = described_class.build([:gzip, :br]) + request = build_request('Accept-Encoding' => 'br, gzip') + compressor = config.negotiate(request) + expect(compressor.encoding).to eq(:gzip) + end + + it 'falls back to second compressor if client does not accept first' do + config = described_class.build([:gzip]) + request = build_request('Accept-Encoding' => 'gzip') + compressor = config.negotiate(request) + expect(compressor.encoding).to eq(:gzip) + end + + it 'returns Null when client encoding not in configured list' do + config = described_class.build([:gzip]) + request = build_request('Accept-Encoding' => 'br') + compressor = config.negotiate(request) + expect(compressor).to be_a(Datastar::Compressor::Null) + end + + it 'returns Null when Accept-Encoding has q=0 for all' do + config = described_class.build(true) + request = build_request('Accept-Encoding' => 'gzip;q=0, br;q=0') + compressor = config.negotiate(request) + expect(compressor).to be_a(Datastar::Compressor::Null) + end + + it 'handles Accept-Encoding with quality values' do + config = described_class.build([:gzip]) + request = build_request('Accept-Encoding' => 'gzip;q=1.0, br;q=0.5') + compressor = config.negotiate(request) + expect(compressor.encoding).to eq(:gzip) + end + end + + describe 'Compressor::NONE' do + subject(:null) { Datastar::Compressor::NONE } + + it 'is a frozen constant' do + expect(null).to be_frozen + expect(null).to equal(Datastar::Compressor::NONE) + end + + it 'returns nil encoding' do + expect(null.encoding).to be_nil + end + + it 'returns the socket unchanged from wrap_socket' do + socket = Object.new + expect(null.wrap_socket(socket)).to equal(socket) + end + + it 'prepare_response is a no-op' do + response = double('response') + expect(null.prepare_response(response)).to be_nil + end + end + + describe 'Compressor::Gzip' do + subject(:compressor) { Datastar::Compressor::Gzip.new({}) } + + it 'has :gzip encoding' do + expect(compressor.encoding).to eq(:gzip) + end + + it 'sets response headers' do + headers = {} + response = double('response', headers: headers) + compressor.prepare_response(response) + expect(headers['Content-Encoding']).to eq('gzip') + expect(headers['Vary']).to eq('Accept-Encoding') + end + + it 'wraps socket in Gzip::CompressedSocket' do + socket = Object.new + wrapped = compressor.wrap_socket(socket) + expect(wrapped).to be_a(Datastar::Compressor::Gzip::CompressedSocket) + end + end + + describe 'Compressor::Brotli' do + subject(:compressor) { Datastar::Compressor::Brotli.new({}) } + + it 'has :br encoding' do + expect(compressor.encoding).to eq(:br) + end + + it 'sets response headers' do + headers = {} + response = double('response', headers: headers) + compressor.prepare_response(response) + expect(headers['Content-Encoding']).to eq('br') + expect(headers['Vary']).to eq('Accept-Encoding') + end + + it 'wraps socket in Brotli::CompressedSocket' do + socket = Object.new + wrapped = compressor.wrap_socket(socket) + expect(wrapped).to be_a(Datastar::Compressor::Brotli::CompressedSocket) + end + end + + private + + def build_request(headers = {}) + env = Rack::MockRequest.env_for('/', headers.transform_keys { |k| "HTTP_#{k.upcase.tr('-', '_')}" }) + Rack::Request.new(env) + end +end diff --git a/spec/dispatcher_spec.rb b/spec/dispatcher_spec.rb index 8c3b36b..09b6157 100644 --- a/spec/dispatcher_spec.rb +++ b/spec/dispatcher_spec.rb @@ -610,8 +610,106 @@ def self.render_in(view_context) = %(
\n#{view_context} 'br, gzip' }) + dispatcher = Datastar.new(request:, response:, view_context:, compression: true) + + expect(dispatcher.response['Content-Encoding']).to eq('br') + expect(dispatcher.response['Vary']).to eq('Accept-Encoding') + end + + it 'sets Content-Encoding: gzip when compression enabled and client accepts gzip only' do + request = build_request('/events', headers: { 'HTTP_ACCEPT_ENCODING' => 'gzip' }) + dispatcher = Datastar.new(request:, response:, view_context:, compression: true) + + expect(dispatcher.response['Content-Encoding']).to eq('gzip') + expect(dispatcher.response['Vary']).to eq('Accept-Encoding') + end + + it 'does not set Content-Encoding when compression enabled but no Accept-Encoding' do + request = build_request('/events') + dispatcher = Datastar.new(request:, response:, view_context:, compression: true) + + expect(dispatcher.response['Content-Encoding']).to be_nil + expect(dispatcher.response['Vary']).to be_nil + end + + it 'does not set Content-Encoding when compression disabled' do + request = build_request('/events', headers: { 'HTTP_ACCEPT_ENCODING' => 'br, gzip' }) + dispatcher = Datastar.new(request:, response:, view_context:, compression: false) + + expect(dispatcher.response['Content-Encoding']).to be_nil + end + + it 'streams gzip-compressed data that decompresses correctly' do + request = build_request('/events', headers: { 'HTTP_ACCEPT_ENCODING' => 'gzip' }) + dispatcher = Datastar.new(request:, response:, view_context:, compression: true, heartbeat: false) + + dispatcher.patch_signals(foo: 'bar') + + raw_socket = BinarySocket.new + dispatcher.response.body.call(raw_socket) + + decompressed = Zlib::Inflate.new(31).inflate(raw_socket.bytes) + expect(decompressed).to include('datastar-patch-signals') + expect(decompressed).to include('"foo":"bar"') + end + + it 'streams brotli-compressed data that decompresses correctly' do + request = build_request('/events', headers: { 'HTTP_ACCEPT_ENCODING' => 'br' }) + dispatcher = Datastar.new(request:, response:, view_context:, compression: true, heartbeat: false) + + dispatcher.patch_signals(foo: 'bar') + + raw_socket = BinarySocket.new + dispatcher.response.body.call(raw_socket) + + decompressed = Brotli.inflate(raw_socket.bytes) + expect(decompressed).to include('datastar-patch-signals') + expect(decompressed).to include('"foo":"bar"') + end + + it 'respects compression order as preference (gzip first)' do + request = build_request('/events', headers: { 'HTTP_ACCEPT_ENCODING' => 'br, gzip' }) + dispatcher = Datastar.new(request:, response:, view_context:, compression: [:gzip, :br]) + + expect(dispatcher.response['Content-Encoding']).to eq('gzip') + end + + it 'respects compression as array of enabled encodings' do + request = build_request('/events', headers: { 'HTTP_ACCEPT_ENCODING' => 'br, gzip' }) + dispatcher = Datastar.new(request:, response:, view_context:, compression: [:gzip]) + + expect(dispatcher.response['Content-Encoding']).to eq('gzip') + end + end + private + # Binary socket for compression tests + class BinarySocket + attr_reader :closed + + def initialize + @buffer = String.new(encoding: Encoding::BINARY) + @closed = false + end + + def <<(data) + @buffer << data.b + self + end + + def close + @closed = true + end + + def bytes + @buffer + end + end + def build_request(path, method: 'GET', body: nil, content_type: 'application/json', accept: 'text/event-stream', headers: {}) headers = { 'HTTP_ACCEPT' => accept,