datastar 1.0.1 → 1.0.2

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc94dbbe291bd86a1aaae4302cd8fdaa0262f06bcbf0bb81ce201099a92de0b7
4
- data.tar.gz: 804810f5e0633c690d329f79e3eb220734cb558767f951be18b383bfdd97fcd9
3
+ metadata.gz: 885e73382637f598ee1feeea492556e30f4dea1dcdd9eae1627649a8dbadc0bc
4
+ data.tar.gz: 2945bedd152787c39ae3843f2cf2c937bdba3d7d8a0ea0f6b9a4d0ac2bf34a18
5
5
  SHA512:
6
- metadata.gz: 22716ea763849a6f6aae0a762809b29662e8dfb279dd2bcd0165b2af3a53cb8cbed31f57c8c4af8db55e1f9e54764243741265b01c6d9772eb28b2197790977b
7
- data.tar.gz: 88efa4fb0a989d5d05548a8264650b89a08f1af6cb6bdcbb848da36e6aa09cc8c37729233ed867fbfdb2a9cfeb3fd8944f27a7b8c618259a569b451d8f0b3a92
6
+ metadata.gz: bc0bb40586da1e305aa87b6c4b830baeaacd671e259aa7105a043ceaebab8659c23df8b12665d1a1bf1b9ca09aa518e275cdc30b5978627da6d818e818cc14f8
7
+ data.tar.gz: 5ae556f5ce37d9805fe920c68ab5164464ec6fabbd2f4355d9d17139dbf381bd0dde65b5c33bf8f0e4de6eda431ece4cc7b20a41080f3362fea9f7daedad580d
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Datastar Ruby SDK
2
2
 
3
- Implement the [Datastart SSE procotocol](https://data-star.dev/reference/sse_events) in Ruby. It can be used in any Rack handler, and Rails controllers.
3
+ Implement the [Datastar SSE procotocol](https://data-star.dev/reference/sse_events) in Ruby. It can be used in any Rack handler, and Rails controllers.
4
4
 
5
5
  ## Installation
6
6
 
@@ -13,7 +13,7 @@ gem 'datastar'
13
13
  Or point your `Gemfile` to the source
14
14
 
15
15
  ```bash
16
- gem 'datastar', git: 'https://github.com/starfederation/datastar', glob: 'sdk/ruby/*.gemspec'
16
+ gem 'datastar', github: 'starfederation/datastar-ruby'
17
17
  ```
18
18
 
19
19
  ## Usage
@@ -270,13 +270,105 @@ Datastar.configure do |config|
270
270
  config.on_error do |exception|
271
271
  Sentry.notify(exception)
272
272
  end
273
-
273
+
274
274
  # Global heartbeat interval (or false, to disable)
275
- # Can be overriden on specific instances
275
+ # Can be overriden on specific instances
276
276
  config.heartbeat = 0.3
277
+
278
+ # Enable compression for SSE streams (default: false)
279
+ # See the Compression section below for details
280
+ config.compression = true
277
281
  end
278
282
  ```
279
283
 
284
+ ### Compression
285
+
286
+ 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.
287
+
288
+ #### Enabling compression
289
+
290
+ Per-instance:
291
+
292
+ ```ruby
293
+ datastar = Datastar.new(request:, response:, view_context:, compression: true)
294
+ ```
295
+
296
+ Or globally:
297
+
298
+ ```ruby
299
+ Datastar.configure do |config|
300
+ config.compression = true
301
+ end
302
+ ```
303
+
304
+ 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.
305
+
306
+ #### Brotli vs gzip
307
+
308
+ 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.
309
+
310
+ To use Brotli, add the gem to your `Gemfile`:
311
+
312
+ ```ruby
313
+ gem 'brotli'
314
+ ```
315
+
316
+ #### Configuration options
317
+
318
+ ```ruby
319
+ Datastar.configure do |config|
320
+ # Enable compression (default: false)
321
+ # true enables both :br and :gzip (br preferred)
322
+ config.compression = true
323
+
324
+ # Or pass an array of encodings (first = preferred)
325
+ config.compression = [:br, :gzip]
326
+
327
+ # Per-encoder options via [symbol, options] pairs
328
+ config.compression = [[:br, { quality: 5 }], :gzip]
329
+ end
330
+ ```
331
+
332
+ You can also set these per-instance:
333
+
334
+ ```ruby
335
+ datastar = Datastar.new(
336
+ request:, response:, view_context:,
337
+ compression: [:gzip] # only gzip, no brotli
338
+ )
339
+
340
+ # Or with per-encoder options
341
+ datastar = Datastar.new(
342
+ request:, response:, view_context:,
343
+ compression: [[:gzip, { level: 1 }]]
344
+ )
345
+ ```
346
+
347
+ #### Per-encoder options
348
+
349
+ Options are passed directly to the underlying compressor via the array form. Available options depend on the encoder.
350
+
351
+ **Gzip** (via `Zlib::Deflate`):
352
+
353
+ | Option | Default | Description |
354
+ | ------------ | --------------------------- | ------------------------------------------------------------ |
355
+ | `:level` | `Zlib::DEFAULT_COMPRESSION` | Compression level (0-9). 0 = none, 1 = fastest, 9 = smallest. `Zlib::BEST_SPEED` and `Zlib::BEST_COMPRESSION` also work. |
356
+ | `:mem_level` | `8` | Memory usage (1-9). Higher uses more memory for better compression. |
357
+ | `:strategy` | `Zlib::DEFAULT_STRATEGY` | Algorithm strategy. Alternatives: `Zlib::FILTERED`, `Zlib::HUFFMAN_ONLY`, `Zlib::RLE`, `Zlib::FIXED`. |
358
+
359
+ **Brotli** (via `Brotli::Compressor`, requires the `brotli` gem):
360
+
361
+ | Option | Default | Description |
362
+ | ---------- | ---------- | ------------------------------------------------------------ |
363
+ | `:quality` | `11` | Compression quality (0-11). Lower is faster, higher compresses better. |
364
+ | `:lgwin` | `22` | Base-2 log of sliding window size (10-24). |
365
+ | `:lgblock` | `0` (auto) | Base-2 log of max input block size (16-24, or 0 for auto). |
366
+ | `:mode` | `:generic` | Compression mode: `:generic`, `:text`, or `:font`. `:text` is a good choice for SSE (UTF-8 HTML/JSON). |
367
+
368
+ #### Proxy considerations
369
+
370
+ 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.
371
+
280
372
  ### Rendering Rails templates
281
373
 
282
374
  In Rails, make sure to initialize Datastar with the `view_context` in a controller.
@@ -364,12 +456,6 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
364
456
 
365
457
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
366
458
 
367
- ### Building
368
-
369
- To build `consts.rb` file from template, run Docker and run `make task build`
370
-
371
- The template is located at `build/consts_ruby.gtpl`.
372
-
373
459
  ## Contributing
374
460
 
375
461
  Bug reports and pull requests are welcome on GitHub at https://github.com/starfederation/datastar.
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Benchmark: SSE compression payload sizes
5
+ #
6
+ # Compares bytes-over-the-wire for no compression, gzip, and brotli
7
+ # when streaming large HTML elements via Datastar's SSE protocol.
8
+ #
9
+ # Usage:
10
+ # bundle exec ruby benchmarks/compression.rb
11
+ #
12
+ # The benchmark patches realistic HTML payloads of increasing size
13
+ # through the full Datastar SSE pipeline (ServerSentEventGenerator →
14
+ # CompressedSocket → raw socket) and reports the resulting byte sizes
15
+ # and compression ratios.
16
+
17
+ require 'bundler/setup'
18
+ require 'datastar'
19
+ require 'datastar/compressor/gzip'
20
+ require 'datastar/compressor/brotli'
21
+
22
+ # --- Payload generators ---------------------------------------------------
23
+
24
+ # A user-row partial, repeated N times inside a <tbody>.
25
+ # Realistic: IDs, data attributes, mixed text, Tailwind-style classes.
26
+ def html_table(row_count)
27
+ rows = row_count.times.map do |i|
28
+ <<~HTML
29
+ <tr id="user-row-#{i}" class="border-b border-gray-200 hover:bg-gray-50 transition-colors duration-150" data-user-id="#{i}" data-signal-selected="false">
30
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{i + 1}</td>
31
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">user-#{i}@example.com</td>
32
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">#{%w[Admin Editor Viewer].sample}</td>
33
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2025-01-#{(i % 28 + 1).to_s.rjust(2, '0')}</td>
34
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
35
+ <button class="text-indigo-600 hover:text-indigo-900 mr-3" data-on-click="$$put('/users/#{i}/edit')">Edit</button>
36
+ <button class="text-red-600 hover:text-red-900" data-on-click="$$delete('/users/#{i}')">Delete</button>
37
+ </td>
38
+ </tr>
39
+ HTML
40
+ end
41
+
42
+ <<~HTML
43
+ <tbody id="users-table-body">
44
+ #{rows.join}
45
+ </tbody>
46
+ HTML
47
+ end
48
+
49
+ # A dashboard card with nested elements — charts placeholder, stats, lists.
50
+ def html_dashboard(card_count)
51
+ cards = card_count.times.map do |i|
52
+ <<~HTML
53
+ <div id="card-#{i}" class="bg-white overflow-hidden shadow-lg rounded-2xl border border-gray-100 p-6 flex flex-col gap-4">
54
+ <div class="flex items-center justify-between">
55
+ <h3 class="text-lg font-semibold text-gray-900">Metric #{i + 1}</h3>
56
+ <span class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">+#{rand(1..99)}%</span>
57
+ </div>
58
+ <div class="text-3xl font-bold text-gray-900">#{rand(1_000..99_999).to_s.chars.each_slice(3).map(&:join).join(',')}</div>
59
+ <div class="h-32 bg-gradient-to-r from-indigo-50 to-indigo-100 rounded-lg flex items-end gap-1 p-2">
60
+ #{8.times.map { |j| "<div class=\"bg-indigo-#{[400, 500, 600].sample} rounded-t w-full\" style=\"height: #{rand(20..100)}%\"></div>" }.join("\n ")}
61
+ </div>
62
+ <ul class="divide-y divide-gray-100">
63
+ #{5.times.map { |j| "<li class=\"flex justify-between py-2 text-sm\"><span class=\"text-gray-500\">Region #{j + 1}</span><span class=\"font-medium text-gray-900\">#{rand(100..9_999)}</span></li>" }.join("\n ")}
64
+ </ul>
65
+ </div>
66
+ HTML
67
+ end
68
+
69
+ <<~HTML
70
+ <div id="dashboard-grid" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
71
+ #{cards.join}
72
+ </div>
73
+ HTML
74
+ end
75
+
76
+ # --- Socket that counts bytes --------------------------------------------
77
+
78
+ class ByteCountingSocket
79
+ attr_reader :total_bytes
80
+
81
+ def initialize
82
+ @total_bytes = 0
83
+ end
84
+
85
+ def <<(data)
86
+ @total_bytes += data.bytesize
87
+ self
88
+ end
89
+
90
+ def close; end
91
+ end
92
+
93
+ # --- Helpers --------------------------------------------------------------
94
+
95
+ # Pipe an HTML payload through the full SSE + compression stack
96
+ # and return the byte count that would go over the wire.
97
+ def measure_bytes(html, encoding)
98
+ socket = ByteCountingSocket.new
99
+
100
+ wrapped = case encoding
101
+ when :none then socket
102
+ when :gzip then Datastar::Compressor::Gzip::CompressedSocket.new(socket)
103
+ when :br then Datastar::Compressor::Brotli::CompressedSocket.new(socket, mode: :text)
104
+ end
105
+
106
+ generator = Datastar::ServerSentEventGenerator.new(
107
+ wrapped,
108
+ signals: {},
109
+ view_context: nil
110
+ )
111
+
112
+ generator.patch_elements(html)
113
+ wrapped.close unless encoding == :none
114
+
115
+ socket.total_bytes
116
+ end
117
+
118
+ def format_bytes(bytes)
119
+ if bytes >= 1024 * 1024
120
+ "%.1f MB" % (bytes / (1024.0 * 1024))
121
+ elsif bytes >= 1024
122
+ "%.1f KB" % (bytes / 1024.0)
123
+ else
124
+ "#{bytes} B"
125
+ end
126
+ end
127
+
128
+ def ratio(original, compressed)
129
+ "%.1f%%" % ((1.0 - compressed.to_f / original) * 100)
130
+ end
131
+
132
+ # --- Run benchmarks -------------------------------------------------------
133
+
134
+ SCENARIOS = [
135
+ ["Table 10 rows", -> { html_table(10) }],
136
+ ["Table 50 rows", -> { html_table(50) }],
137
+ ["Table 200 rows", -> { html_table(200) }],
138
+ ["Table 1000 rows", -> { html_table(1000) }],
139
+ ["Dashboard 5 cards", -> { html_dashboard(5) }],
140
+ ["Dashboard 20 cards",-> { html_dashboard(20) }],
141
+ ["Dashboard 50 cards",-> { html_dashboard(50) }],
142
+ ]
143
+
144
+ ENCODINGS = %i[none gzip br]
145
+
146
+ # Header
147
+ puts "Datastar SSE Compression Benchmark"
148
+ puts "=" * 90
149
+ puts
150
+ puts format(
151
+ "%-22s %12s %12s %8s %12s %8s",
152
+ "Scenario", "No Compress", "Gzip", "Saved", "Brotli", "Saved"
153
+ )
154
+ puts "-" * 90
155
+
156
+ SCENARIOS.each do |name, generator|
157
+ html = generator.call
158
+ results = ENCODINGS.map { |enc| [enc, measure_bytes(html, enc)] }.to_h
159
+ none = results[:none]
160
+
161
+ puts format(
162
+ "%-22s %12s %12s %8s %12s %8s",
163
+ name,
164
+ format_bytes(none),
165
+ format_bytes(results[:gzip]),
166
+ ratio(none, results[:gzip]),
167
+ format_bytes(results[:br]),
168
+ ratio(none, results[:br])
169
+ )
170
+ end
171
+
172
+ # --- Streaming: multiple SSE events over one connection -------------------
173
+
174
+ # Simulates a long-lived SSE connection where rows are patched individually
175
+ # (e.g. a live-updating table). The compressor stays open across events,
176
+ # so repeated structure (CSS classes, attribute patterns) compresses
177
+ # increasingly well as the dictionary builds up.
178
+
179
+ def measure_streaming_bytes(payloads, encoding)
180
+ socket = ByteCountingSocket.new
181
+
182
+ wrapped = case encoding
183
+ when :none then socket
184
+ when :gzip then Datastar::Compressor::Gzip::CompressedSocket.new(socket)
185
+ when :br then Datastar::Compressor::Brotli::CompressedSocket.new(socket, mode: :text)
186
+ end
187
+
188
+ generator = Datastar::ServerSentEventGenerator.new(
189
+ wrapped,
190
+ signals: {},
191
+ view_context: nil
192
+ )
193
+
194
+ payloads.each { |html| generator.patch_elements(html) }
195
+ wrapped.close unless encoding == :none
196
+
197
+ socket.total_bytes
198
+ end
199
+
200
+ def table_rows(count)
201
+ count.times.map do |i|
202
+ <<~HTML
203
+ <tr id="user-row-#{i}" class="border-b border-gray-200 hover:bg-gray-50 transition-colors duration-150" data-user-id="#{i}" data-signal-selected="false">
204
+ <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">#{i + 1}</td>
205
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">user-#{i}@example.com</td>
206
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">#{%w[Admin Editor Viewer].sample}</td>
207
+ <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2025-01-#{(i % 28 + 1).to_s.rjust(2, '0')}</td>
208
+ <td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
209
+ <button class="text-indigo-600 hover:text-indigo-900 mr-3" data-on-click="$$put('/users/#{i}/edit')">Edit</button>
210
+ <button class="text-red-600 hover:text-red-900" data-on-click="$$delete('/users/#{i}')">Delete</button>
211
+ </td>
212
+ </tr>
213
+ HTML
214
+ end
215
+ end
216
+
217
+ puts
218
+ puts
219
+ puts "Streaming: individual row patches over one SSE connection"
220
+ puts "=" * 90
221
+ puts
222
+ puts format(
223
+ "%-22s %12s %12s %8s %12s %8s",
224
+ "Scenario", "No Compress", "Gzip", "Saved", "Brotli", "Saved"
225
+ )
226
+ puts "-" * 90
227
+
228
+ [10, 50, 200, 1000].each do |count|
229
+ payloads = table_rows(count)
230
+ results = ENCODINGS.map { |enc| [enc, measure_streaming_bytes(payloads, enc)] }.to_h
231
+ none = results[:none]
232
+
233
+ puts format(
234
+ "%-22s %12s %12s %8s %12s %8s",
235
+ "#{count} row patches",
236
+ format_bytes(none),
237
+ format_bytes(results[:gzip]),
238
+ ratio(none, results[:gzip]),
239
+ format_bytes(results[:br]),
240
+ ratio(none, results[:br])
241
+ )
242
+ end
243
+
244
+ puts
245
+ puts "Notes:"
246
+ puts " - Single-event sizes include full SSE framing (event: / data: prefixes)"
247
+ puts " - Gzip: default compression level, gzip framing (window_bits=31)"
248
+ puts " - Brotli: default quality (11) with mode: :text"
249
+ puts " - Streaming rows: each row is a separate patch_elements SSE event"
250
+ puts " over one persistent compressed connection. The compressor dictionary"
251
+ puts " builds up across events, improving ratios for repetitive markup."
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module Datastar
6
+ module Compressor
7
+ # Null compressor — no-op, used when compression is disabled or no match.
8
+ class Null
9
+ def encoding = nil
10
+ def wrap_socket(socket) = socket
11
+ def prepare_response(_response) = nil
12
+ end
13
+
14
+ NONE = Null.new.freeze
15
+ end
16
+
17
+ # Immutable value object that holds an ordered list of pre-built compressors
18
+ # and negotiates the best one for a given request.
19
+ #
20
+ # Use {.build} to create instances from user-facing configuration values.
21
+ # The first compressor in the list is preferred when the client supports multiple.
22
+ #
23
+ # @example Via global configuration
24
+ # Datastar.configure do |config|
25
+ # config.compression = true # [:br, :gzip] with default options
26
+ # config.compression = [:br, :gzip] # preferred = first in list
27
+ # config.compression = [[:br, { quality: 5 }], :gzip] # per-encoder options
28
+ # end
29
+ #
30
+ # @example Per-request negotiation (used internally by Dispatcher)
31
+ # compressor = Datastar.config.compression.negotiate(request)
32
+ # compressor.prepare_response(response)
33
+ # socket = compressor.wrap_socket(raw_socket)
34
+ class CompressionConfig
35
+ ACCEPT_ENCODING = 'HTTP_ACCEPT_ENCODING'
36
+ BLANK_HASH = {}.freeze
37
+
38
+ # Build a {CompressionConfig} from various user-facing input forms.
39
+ #
40
+ # @param input [Boolean, Array<Symbol, Array(Symbol, Hash)>, CompressionConfig]
41
+ # - +false+ / +nil+ — compression disabled (empty compressor list)
42
+ # - +true+ — enable +:br+ and +:gzip+ with default options
43
+ # - +Array<Symbol>+ — enable listed encodings with default options, e.g. +[:gzip]+
44
+ # - +Array<Array(Symbol, Hash)>+ — enable with per-encoder options,
45
+ # e.g. +[[:br, { quality: 5 }], :gzip]+
46
+ # - +CompressionConfig+ — returned as-is
47
+ # @return [CompressionConfig]
48
+ # @raise [ArgumentError] if +input+ is not a recognised form
49
+ # @raise [LoadError] if a requested encoder's gem is not available (e.g. +brotli+)
50
+ #
51
+ # @example Disable compression
52
+ # CompressionConfig.build(false)
53
+ #
54
+ # @example Enable all supported encodings
55
+ # CompressionConfig.build(true)
56
+ #
57
+ # @example Gzip only, with custom level
58
+ # CompressionConfig.build([[:gzip, { level: 1 }]])
59
+ def self.build(input)
60
+ case input
61
+ when CompressionConfig
62
+ input
63
+ when false, nil
64
+ new([])
65
+ when true
66
+ new([build_compressor(:br), build_compressor(:gzip)])
67
+ when Array
68
+ compressors = input.map do |entry|
69
+ case entry
70
+ when Symbol
71
+ build_compressor(entry)
72
+ when Array
73
+ name, options = entry
74
+ build_compressor(name, options || BLANK_HASH)
75
+ else
76
+ raise ArgumentError, "Invalid compression entry: #{entry.inspect}. Expected Symbol or [Symbol, Hash]."
77
+ end
78
+ end
79
+ new(compressors)
80
+ else
81
+ raise ArgumentError, "Invalid compression value: #{input.inspect}. Expected true, false, or Array."
82
+ end
83
+ end
84
+
85
+ def self.build_compressor(name, options = BLANK_HASH)
86
+ case name
87
+ when :br
88
+ require_relative 'compressor/brotli'
89
+ Compressor::Brotli.new(options)
90
+ when :gzip
91
+ require_relative 'compressor/gzip'
92
+ Compressor::Gzip.new(options)
93
+ else
94
+ raise ArgumentError, "Unknown compressor: #{name.inspect}. Expected :br or :gzip."
95
+ end
96
+ end
97
+ private_class_method :build_compressor
98
+
99
+ # @param compressors [Array<Compressor::Gzip, Compressor::Brotli>]
100
+ # ordered list of pre-built compressor instances. First = preferred.
101
+ def initialize(compressors)
102
+ @compressors = compressors.freeze
103
+ freeze
104
+ end
105
+
106
+ # Whether any compressors are configured.
107
+ #
108
+ # @return [Boolean]
109
+ #
110
+ # @example
111
+ # CompressionConfig.build(false).enabled? # => false
112
+ # CompressionConfig.build(true).enabled? # => true
113
+ def enabled?
114
+ @compressors.any?
115
+ end
116
+
117
+ # Negotiate compression with the client based on the +Accept-Encoding+ header.
118
+ #
119
+ # Iterates the configured compressors in order (first = preferred) and returns
120
+ # the first one whose encoding the client accepts. Returns {Compressor::NONE}
121
+ # when compression is disabled, the header is absent, or no match is found.
122
+ #
123
+ # No objects are created per-request — compressors are pre-built and reused.
124
+ #
125
+ # @param request [Rack::Request]
126
+ # @return [Compressor::Gzip, Compressor::Brotli, Compressor::Null]
127
+ #
128
+ # @example
129
+ # config = CompressionConfig.build([:gzip, :br])
130
+ # compressor = config.negotiate(request)
131
+ # compressor.prepare_response(response)
132
+ # socket = compressor.wrap_socket(raw_socket)
133
+ def negotiate(request)
134
+ return Compressor::NONE unless enabled?
135
+
136
+ accepted = parse_accept_encoding(request.get_header(ACCEPT_ENCODING).to_s)
137
+ return Compressor::NONE if accepted.empty?
138
+
139
+ @compressors.each do |compressor|
140
+ return compressor if accepted.include?(compressor.encoding)
141
+ end
142
+
143
+ Compressor::NONE
144
+ end
145
+
146
+ private
147
+
148
+ # Parse Accept-Encoding header into a set of encoding symbols
149
+ # @param header [String]
150
+ # @return [Set<Symbol>]
151
+ def parse_accept_encoding(header)
152
+ return Set.new if header.empty?
153
+
154
+ encodings = Set.new
155
+ header.split(',').each do |part|
156
+ encoding, quality = part.strip.split(';', 2)
157
+ encoding = encoding.strip.downcase
158
+ if quality
159
+ q_val = quality.strip.match(/q=(\d+\.?\d*)/)
160
+ next if q_val && q_val[1].to_f == 0
161
+ end
162
+ encodings << encoding.to_sym
163
+ end
164
+ encodings
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'brotli'
4
+
5
+ module Datastar
6
+ module Compressor
7
+ # Brotli compressor — built once at config time, reused across requests.
8
+ # Eagerly requires the brotli gem; raises LoadError at boot if missing.
9
+ class Brotli
10
+ attr_reader :encoding
11
+
12
+ def initialize(options)
13
+ @options = options.freeze
14
+ @encoding = :br
15
+ freeze
16
+ end
17
+
18
+ def prepare_response(response)
19
+ response.headers['Content-Encoding'] = 'br'
20
+ response.headers['Vary'] = 'Accept-Encoding'
21
+ end
22
+
23
+ def wrap_socket(socket)
24
+ CompressedSocket.new(socket, @options)
25
+ end
26
+
27
+ # Brotli compressed socket using the `brotli` gem.
28
+ # Options are passed directly to Brotli::Compressor.new:
29
+ # :quality - Compression quality (0-11, default: 11). Lower is faster, higher compresses better.
30
+ # :lgwin - Base-2 log of the sliding window size (10-24, default: 22).
31
+ # :lgblock - Base-2 log of the maximum input block size (16-24, 0 = auto, default: 0).
32
+ # :mode - Compression mode (:generic, :text, or :font, default: :generic).
33
+ # Use :text for UTF-8 formatted text (HTML, JSON — good for SSE).
34
+ class CompressedSocket
35
+ def initialize(socket, options = {})
36
+ @socket = socket
37
+ @compressor = ::Brotli::Compressor.new(options)
38
+ end
39
+
40
+ def <<(data)
41
+ compressed = @compressor.process(data)
42
+ @socket << compressed if compressed && !compressed.empty?
43
+ flushed = @compressor.flush
44
+ @socket << flushed if flushed && !flushed.empty?
45
+ self
46
+ end
47
+
48
+ def close
49
+ final = @compressor.finish
50
+ @socket << final if final && !final.empty?
51
+ @socket.close
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'zlib'
4
+
5
+ module Datastar
6
+ module Compressor
7
+ # Gzip compressor — built once at config time, reused across requests.
8
+ class Gzip
9
+ attr_reader :encoding
10
+
11
+ def initialize(options)
12
+ @options = options.freeze
13
+ @encoding = :gzip
14
+ freeze
15
+ end
16
+
17
+ def prepare_response(response)
18
+ response.headers['Content-Encoding'] = 'gzip'
19
+ response.headers['Vary'] = 'Accept-Encoding'
20
+ end
21
+
22
+ def wrap_socket(socket)
23
+ CompressedSocket.new(socket, @options)
24
+ end
25
+
26
+ # Gzip compressed socket using Ruby's built-in zlib.
27
+ # Options:
28
+ # :level - Compression level (0-9, default: Zlib::DEFAULT_COMPRESSION).
29
+ # 0 = no compression, 1 = best speed, 9 = best compression.
30
+ # Zlib::BEST_SPEED (1) and Zlib::BEST_COMPRESSION (9) also work.
31
+ # :mem_level - Memory usage level (1-9, default: 8). Higher uses more memory for better compression.
32
+ # :strategy - Compression strategy (default: Zlib::DEFAULT_STRATEGY).
33
+ # Zlib::FILTERED, Zlib::HUFFMAN_ONLY, Zlib::RLE, Zlib::FIXED are also available.
34
+ class CompressedSocket
35
+ def initialize(socket, options = {})
36
+ level = options.fetch(:level, Zlib::DEFAULT_COMPRESSION)
37
+ mem_level = options.fetch(:mem_level, Zlib::DEF_MEM_LEVEL)
38
+ strategy = options.fetch(:strategy, Zlib::DEFAULT_STRATEGY)
39
+ # Use raw deflate with gzip wrapping (window_bits 31 = 15 + 16)
40
+ @socket = socket
41
+ @deflate = Zlib::Deflate.new(level, 31, mem_level, strategy)
42
+ end
43
+
44
+ def <<(data)
45
+ compressed = @deflate.deflate(data, Zlib::SYNC_FLUSH)
46
+ @socket << compressed if compressed && !compressed.empty?
47
+ self
48
+ end
49
+
50
+ def close
51
+ final = @deflate.finish
52
+ @socket << final if final && !final.empty?
53
+ @socket.close
54
+ ensure
55
+ @deflate.close
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -35,6 +35,7 @@ module Datastar
35
35
  DEFAULT_HEARTBEAT = 3
36
36
 
37
37
  attr_accessor :executor, :error_callback, :finalize, :heartbeat, :logger
38
+ attr_reader :compression
38
39
 
39
40
  def initialize
40
41
  @executor = ThreadExecutor.new
@@ -44,6 +45,11 @@ module Datastar
44
45
  @error_callback = proc do |e|
45
46
  @logger.error("#{e.class} (#{e.message}):\n#{e.backtrace.join("\n")}")
46
47
  end
48
+ @compression = CompressionConfig.build(false)
49
+ end
50
+
51
+ def compression=(value)
52
+ @compression = value.is_a?(CompressionConfig) ? value : CompressionConfig.build(value)
47
53
  end
48
54
 
49
55
  def on_error(callable = nil, &block)
@@ -44,7 +44,8 @@ module Datastar
44
44
  executor: Datastar.config.executor,
45
45
  error_callback: Datastar.config.error_callback,
46
46
  finalize: Datastar.config.finalize,
47
- heartbeat: Datastar.config.heartbeat
47
+ heartbeat: Datastar.config.heartbeat,
48
+ compression: Datastar.config.compression
48
49
  )
49
50
  @on_connect = []
50
51
  @on_client_disconnect = []
@@ -68,6 +69,11 @@ module Datastar
68
69
 
69
70
  @heartbeat = heartbeat
70
71
  @heartbeat_on = false
72
+
73
+ # Negotiate compression
74
+ compression = CompressionConfig.build(compression) unless compression.is_a?(CompressionConfig)
75
+ @compressor = compression.negotiate(request)
76
+ @compressor.prepare_response(@response)
71
77
  end
72
78
 
73
79
  # Check if the request accepts SSE responses
@@ -283,6 +289,7 @@ module Datastar
283
289
  # @api private
284
290
  def stream_one(streamer)
285
291
  proc do |socket|
292
+ socket = wrap_socket(socket)
286
293
  generator = ServerSentEventGenerator.new(socket, signals:, view_context: @view_context)
287
294
  @on_connect.each { |callable| callable.call(generator) }
288
295
  handling_sync_errors(generator, socket) do
@@ -308,6 +315,7 @@ module Datastar
308
315
  @queue ||= @executor.new_queue
309
316
 
310
317
  proc do |socket|
318
+ socket = wrap_socket(socket)
311
319
  signs = signals
312
320
  conn_generator = ServerSentEventGenerator.new(socket, signals: signs, view_context: @view_context)
313
321
  @on_connect.each { |callable| callable.call(conn_generator) }
@@ -360,6 +368,13 @@ module Datastar
360
368
  end
361
369
  end
362
370
 
371
+ # Wrap socket in a CompressedSocket if compression is negotiated
372
+ # @param socket [IO]
373
+ # @return [CompressedSocket, IO]
374
+ def wrap_socket(socket)
375
+ @compressor.wrap_socket(socket)
376
+ end
377
+
363
378
  # Handle errors caught during streaming
364
379
  # @param error [Exception] the error that occurred
365
380
  # @param socket [IO] the socket to pass to error handlers
@@ -3,9 +3,46 @@
3
3
  require 'json'
4
4
 
5
5
  module Datastar
6
+ module ElementPatchMode
7
+ # Morphs the element into the existing element.
8
+ OUTER = 'outer'
9
+
10
+ # Replaces the inner HTML of the existing element.
11
+ INNER = 'inner'
12
+
13
+ # Removes the existing element.
14
+ REMOVE = 'remove'
15
+
16
+ # Replaces the existing element with the new element.
17
+ REPLACE = 'replace'
18
+
19
+ # Prepends the element inside to the existing element.
20
+ PREPEND = 'prepend'
21
+
22
+ # Appends the element inside the existing element.
23
+ APPEND = 'append'
24
+
25
+ # Inserts the element before the existing element.
26
+ BEFORE = 'before'
27
+
28
+ # Inserts the element after the existing element.
29
+ AFTER = 'after'
30
+ end
31
+
6
32
  class ServerSentEventGenerator
7
33
  MSG_END = "\n"
8
34
 
35
+ DEFAULT_SSE_RETRY_DURATION = 1000
36
+ DEFAULT_ELEMENTS_USE_VIEW_TRANSITIONS = false
37
+ DEFAULT_PATCH_SIGNALS_ONLY_IF_MISSING = false
38
+
39
+ SELECTOR_DATALINE_LITERAL = 'selector'
40
+ MODE_DATALINE_LITERAL = 'mode'
41
+ ELEMENTS_DATALINE_LITERAL = 'elements'
42
+ USE_VIEW_TRANSITION_DATALINE_LITERAL = 'useViewTransition'
43
+ SIGNALS_DATALINE_LITERAL = 'signals'
44
+ ONLY_IF_MISSING_DATALINE_LITERAL = 'onlyIfMissing'
45
+
9
46
  SSE_OPTION_MAPPING = {
10
47
  'eventId' => 'id',
11
48
  'retryDuration' => 'retry',
@@ -13,11 +50,13 @@ module Datastar
13
50
  'retry' => 'retry',
14
51
  }.freeze
15
52
 
53
+ DEFAULT_ELEMENT_PATCH_MODE = ElementPatchMode::OUTER
54
+
16
55
  OPTION_DEFAULTS = {
17
- 'retry' => Consts::DEFAULT_SSE_RETRY_DURATION,
18
- Consts::MODE_DATALINE_LITERAL => Consts::DEFAULT_ELEMENT_PATCH_MODE,
19
- Consts::USE_VIEW_TRANSITION_DATALINE_LITERAL => Consts::DEFAULT_ELEMENTS_USE_VIEW_TRANSITIONS,
20
- Consts::ONLY_IF_MISSING_DATALINE_LITERAL => Consts::DEFAULT_PATCH_SIGNALS_ONLY_IF_MISSING,
56
+ 'retry' => DEFAULT_SSE_RETRY_DURATION,
57
+ MODE_DATALINE_LITERAL => DEFAULT_ELEMENT_PATCH_MODE,
58
+ USE_VIEW_TRANSITION_DATALINE_LITERAL => DEFAULT_ELEMENTS_USE_VIEW_TRANSITIONS,
59
+ ONLY_IF_MISSING_DATALINE_LITERAL => DEFAULT_PATCH_SIGNALS_ONLY_IF_MISSING,
21
60
  }.freeze
22
61
 
23
62
  SIGNAL_SEPARATOR = '.'
@@ -52,7 +91,7 @@ module Datastar
52
91
 
53
92
  buffer = +"event: datastar-patch-elements\n"
54
93
  build_options(options, buffer)
55
- element_lines.each { |line| buffer << "data: #{Consts::ELEMENTS_DATALINE_LITERAL} #{line}\n" }
94
+ element_lines.each { |line| buffer << "data: #{ELEMENTS_DATALINE_LITERAL} #{line}\n" }
56
95
 
57
96
  write(buffer)
58
97
  end
@@ -61,7 +100,7 @@ module Datastar
61
100
  patch_elements(
62
101
  nil,
63
102
  options.merge(
64
- Consts::MODE_DATALINE_LITERAL => Consts::ElementPatchMode::REMOVE,
103
+ MODE_DATALINE_LITERAL => ElementPatchMode::REMOVE,
65
104
  selector:
66
105
  )
67
106
  )
@@ -75,7 +114,7 @@ module Datastar
75
114
  signals = JSON.dump(signals)
76
115
  buffer << "data: signals #{signals}\n"
77
116
  when String
78
- multi_data_lines(signals, buffer, Consts::SIGNALS_DATALINE_LITERAL)
117
+ multi_data_lines(signals, buffer, SIGNALS_DATALINE_LITERAL)
79
118
  end
80
119
  write(buffer)
81
120
  end
@@ -101,8 +140,8 @@ module Datastar
101
140
  script_tag << %( data-effect="el.remove()") if auto_remove
102
141
  script_tag << ">#{script}</script>"
103
142
 
104
- options[Consts::SELECTOR_DATALINE_LITERAL] = 'body'
105
- options[Consts::MODE_DATALINE_LITERAL] = Consts::ElementPatchMode::APPEND
143
+ options[SELECTOR_DATALINE_LITERAL] = 'body'
144
+ options[MODE_DATALINE_LITERAL] = ElementPatchMode::APPEND
106
145
 
107
146
  patch_elements(script_tag, options)
108
147
  end
@@ -143,7 +182,7 @@ module Datastar
143
182
  buffer << "data: #{k} #{kk} #{vv}\n"
144
183
  end
145
184
  elsif v.is_a?(Array)
146
- if k == Consts::SELECTOR_DATALINE_LITERAL
185
+ if k == SELECTOR_DATALINE_LITERAL
147
186
  buffer << "data: #{k} #{v.join(', ')}\n"
148
187
  else
149
188
  buffer << "data: #{k} #{v.join(' ')}\n"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Datastar
4
- VERSION = '1.0.1'
4
+ VERSION = '1.0.2'
5
5
  end
data/lib/datastar.rb CHANGED
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'datastar/version'
4
- require_relative 'datastar/consts'
5
-
6
4
  module Datastar
7
5
  BLANK_OPTIONS = {}.freeze
8
6
 
@@ -27,6 +25,7 @@ module Datastar
27
25
  end
28
26
 
29
27
  require_relative 'datastar/configuration'
28
+ require_relative 'datastar/compression_config'
30
29
  require_relative 'datastar/dispatcher'
31
30
  require_relative 'datastar/server_sent_event_generator'
32
31
  require_relative 'datastar/railtie' if defined?(Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: datastar
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.1
4
+ version: 1.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ismael Celis
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 3.1.14
18
+ version: '3.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 3.1.14
25
+ version: '3.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: json
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -75,6 +75,7 @@ files:
75
75
  - LICENSE.md
76
76
  - README.md
77
77
  - Rakefile
78
+ - benchmarks/compression.rb
78
79
  - examples/hello-world/Gemfile
79
80
  - examples/hello-world/Gemfile.lock
80
81
  - examples/hello-world/hello-world.html
@@ -86,8 +87,10 @@ files:
86
87
  - examples/threads/threads.ru
87
88
  - lib/datastar.rb
88
89
  - lib/datastar/async_executor.rb
90
+ - lib/datastar/compression_config.rb
91
+ - lib/datastar/compressor/brotli.rb
92
+ - lib/datastar/compressor/gzip.rb
89
93
  - lib/datastar/configuration.rb
90
- - lib/datastar/consts.rb
91
94
  - lib/datastar/dispatcher.rb
92
95
  - lib/datastar/rails_async_executor.rb
93
96
  - lib/datastar/rails_thread_executor.rb
@@ -114,7 +117,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
114
117
  - !ruby/object:Gem::Version
115
118
  version: '0'
116
119
  requirements: []
117
- rubygems_version: 3.7.2
120
+ rubygems_version: 4.0.8
118
121
  specification_version: 4
119
122
  summary: Ruby SDK for Datastar. Rack-compatible.
120
123
  test_files: []
@@ -1,57 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This is auto-generated by Datastar. DO NOT EDIT.
4
- module Datastar
5
- module Consts
6
- DATASTAR_KEY = 'datastar'
7
- VERSION = '1.0.0-RC.1'
8
-
9
- # The default duration for retrying SSE on connection reset. This is part of the underlying retry mechanism of SSE.
10
- DEFAULT_SSE_RETRY_DURATION = 1000
11
-
12
- # Should elements be patched using the ViewTransition API?
13
- DEFAULT_ELEMENTS_USE_VIEW_TRANSITIONS = false
14
-
15
- # Should a given set of signals patch if they are missing?
16
- DEFAULT_PATCH_SIGNALS_ONLY_IF_MISSING = false
17
-
18
- module ElementPatchMode
19
-
20
- # Morphs the element into the existing element.
21
- OUTER = 'outer';
22
-
23
- # Replaces the inner HTML of the existing element.
24
- INNER = 'inner';
25
-
26
- # Removes the existing element.
27
- REMOVE = 'remove';
28
-
29
- # Replaces the existing element with the new element.
30
- REPLACE = 'replace';
31
-
32
- # Prepends the element inside to the existing element.
33
- PREPEND = 'prepend';
34
-
35
- # Appends the element inside the existing element.
36
- APPEND = 'append';
37
-
38
- # Inserts the element before the existing element.
39
- BEFORE = 'before';
40
-
41
- # Inserts the element after the existing element.
42
- AFTER = 'after';
43
- end
44
-
45
-
46
- # The mode in which an element is patched into the DOM.
47
- DEFAULT_ELEMENT_PATCH_MODE = ElementPatchMode::OUTER
48
-
49
- # Dataline literals.
50
- SELECTOR_DATALINE_LITERAL = 'selector'
51
- MODE_DATALINE_LITERAL = 'mode'
52
- ELEMENTS_DATALINE_LITERAL = 'elements'
53
- USE_VIEW_TRANSITION_DATALINE_LITERAL = 'useViewTransition'
54
- SIGNALS_DATALINE_LITERAL = 'signals'
55
- ONLY_IF_MISSING_DATALINE_LITERAL = 'onlyIfMissing'
56
- end
57
- end