datastar 1.0.1 → 1.0.3

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: 568e2026e9657b4042957c360641bc3c1b51bfdfe7dcf1710a78b75e07a71ccf
4
+ data.tar.gz: 25851c3fc4b6bb74c8db43d6ecfbbd8fa796be259fe9b0b4bc4653e3077c0e41
5
5
  SHA512:
6
- metadata.gz: 22716ea763849a6f6aae0a762809b29662e8dfb279dd2bcd0165b2af3a53cb8cbed31f57c8c4af8db55e1f9e54764243741265b01c6d9772eb28b2197790977b
7
- data.tar.gz: 88efa4fb0a989d5d05548a8264650b89a08f1af6cb6bdcbb848da36e6aa09cc8c37729233ed867fbfdb2a9cfeb3fd8944f27a7b8c618259a569b451d8f0b3a92
6
+ metadata.gz: 880721f4912523484e4c79c8faeba5241e371007d5a6d81cbf45f1979ef4d25a6a1c32c25afe24165c4e646bf1523355ae9bea9a13310da2a05287fab71acb82
7
+ data.tar.gz: 9b594d6249a5a923d8da9d693e495d8f1622835bd8b698c5e8ff8396bbf00f5ac6f5c6302591ac9ae4690f06c7b6e9dc1c9e752d9d9f3757e74648349472ba3d
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
@@ -245,6 +245,21 @@ You can also set it to a different number (in seconds)
245
245
  heartbeat: 0.5
246
246
  ```
247
247
 
248
+ #### Per-stream override
249
+
250
+ The `#stream` method also accepts a `heartbeat:` keyword that overrides the constructor-level setting for a single call. This is useful when a dispatcher is generally configured with a heartbeat but a particular response doesn't need one (e.g. a one-shot update). The previous value is restored once the call returns.
251
+
252
+ ```ruby
253
+ datastar = Datastar.new(request:, response:) # default heartbeat
254
+
255
+ # Disable heartbeat for this single response
256
+ datastar.stream(heartbeat: false) do |sse|
257
+ sse.patch_elements(html)
258
+ end
259
+ ```
260
+
261
+ The one-shot helpers (`#patch_elements`, `#remove_elements`, `#patch_signals`, `#remove_signals`, `#execute_script`, `#redirect`) use this internally to avoid spawning a heartbeat thread for a single message.
262
+
248
263
  #### Manual connection check
249
264
 
250
265
  If you want to check connection status on your own, you can disable the heartbeat and use `sse.check_connection!`, which will close the connection and trigger callbacks if the client is disconnected.
@@ -270,13 +285,105 @@ Datastar.configure do |config|
270
285
  config.on_error do |exception|
271
286
  Sentry.notify(exception)
272
287
  end
273
-
288
+
274
289
  # Global heartbeat interval (or false, to disable)
275
- # Can be overriden on specific instances
290
+ # Can be overriden on specific instances
276
291
  config.heartbeat = 0.3
292
+
293
+ # Enable compression for SSE streams (default: false)
294
+ # See the Compression section below for details
295
+ config.compression = true
277
296
  end
278
297
  ```
279
298
 
299
+ ### Compression
300
+
301
+ 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.
302
+
303
+ #### Enabling compression
304
+
305
+ Per-instance:
306
+
307
+ ```ruby
308
+ datastar = Datastar.new(request:, response:, view_context:, compression: true)
309
+ ```
310
+
311
+ Or globally:
312
+
313
+ ```ruby
314
+ Datastar.configure do |config|
315
+ config.compression = true
316
+ end
317
+ ```
318
+
319
+ 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.
320
+
321
+ #### Brotli vs gzip
322
+
323
+ 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.
324
+
325
+ To use Brotli, add the gem to your `Gemfile`:
326
+
327
+ ```ruby
328
+ gem 'brotli'
329
+ ```
330
+
331
+ #### Configuration options
332
+
333
+ ```ruby
334
+ Datastar.configure do |config|
335
+ # Enable compression (default: false)
336
+ # true enables both :br and :gzip (br preferred)
337
+ config.compression = true
338
+
339
+ # Or pass an array of encodings (first = preferred)
340
+ config.compression = [:br, :gzip]
341
+
342
+ # Per-encoder options via [symbol, options] pairs
343
+ config.compression = [[:br, { quality: 5 }], :gzip]
344
+ end
345
+ ```
346
+
347
+ You can also set these per-instance:
348
+
349
+ ```ruby
350
+ datastar = Datastar.new(
351
+ request:, response:, view_context:,
352
+ compression: [:gzip] # only gzip, no brotli
353
+ )
354
+
355
+ # Or with per-encoder options
356
+ datastar = Datastar.new(
357
+ request:, response:, view_context:,
358
+ compression: [[:gzip, { level: 1 }]]
359
+ )
360
+ ```
361
+
362
+ #### Per-encoder options
363
+
364
+ Options are passed directly to the underlying compressor via the array form. Available options depend on the encoder.
365
+
366
+ **Gzip** (via `Zlib::Deflate`):
367
+
368
+ | Option | Default | Description |
369
+ | ------------ | --------------------------- | ------------------------------------------------------------ |
370
+ | `:level` | `Zlib::DEFAULT_COMPRESSION` | Compression level (0-9). 0 = none, 1 = fastest, 9 = smallest. `Zlib::BEST_SPEED` and `Zlib::BEST_COMPRESSION` also work. |
371
+ | `:mem_level` | `8` | Memory usage (1-9). Higher uses more memory for better compression. |
372
+ | `:strategy` | `Zlib::DEFAULT_STRATEGY` | Algorithm strategy. Alternatives: `Zlib::FILTERED`, `Zlib::HUFFMAN_ONLY`, `Zlib::RLE`, `Zlib::FIXED`. |
373
+
374
+ **Brotli** (via `Brotli::Compressor`, requires the `brotli` gem):
375
+
376
+ | Option | Default | Description |
377
+ | ---------- | ---------- | ------------------------------------------------------------ |
378
+ | `:quality` | `11` | Compression quality (0-11). Lower is faster, higher compresses better. |
379
+ | `:lgwin` | `22` | Base-2 log of sliding window size (10-24). |
380
+ | `:lgblock` | `0` (auto) | Base-2 log of max input block size (16-24, or 0 for auto). |
381
+ | `:mode` | `:generic` | Compression mode: `:generic`, `:text`, or `:font`. `:text` is a good choice for SSE (UTF-8 HTML/JSON). |
382
+
383
+ #### Proxy considerations
384
+
385
+ 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.
386
+
280
387
  ### Rendering Rails templates
281
388
 
282
389
  In Rails, make sure to initialize Datastar with the `view_context` in a controller.
@@ -364,12 +471,6 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
364
471
 
365
472
  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
473
 
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
474
  ## Contributing
374
475
 
375
476
  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)
@@ -28,7 +28,7 @@ module Datastar
28
28
  HTTP_ACCEPT = 'HTTP_ACCEPT'
29
29
  HTTP1 = 'HTTP/1.1'
30
30
 
31
- attr_reader :request, :response
31
+ attr_reader :request, :response, :heartbeat
32
32
 
33
33
  # @option request [Rack::Request] the request object
34
34
  # @option response [Rack::Response, nil] the response object
@@ -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
@@ -131,7 +137,7 @@ module Datastar
131
137
  # @param elements [String, #call(view_context: Object) => Object] the HTML elements or object
132
138
  # @param options [Hash] the options to send with the message
133
139
  def patch_elements(elements, options = BLANK_OPTIONS)
134
- stream_no_heartbeat do |sse|
140
+ stream(heartbeat: false) do |sse|
135
141
  sse.patch_elements(elements, options)
136
142
  end
137
143
  end
@@ -146,7 +152,7 @@ module Datastar
146
152
  # @param selector [String] a CSS selector for the fragment to remove
147
153
  # @param options [Hash] the options to send with the message
148
154
  def remove_elements(selector, options = BLANK_OPTIONS)
149
- stream_no_heartbeat do |sse|
155
+ stream(heartbeat: false) do |sse|
150
156
  sse.remove_elements(selector, options)
151
157
  end
152
158
  end
@@ -160,7 +166,7 @@ module Datastar
160
166
  # @param signals [Hash, String] signals to merge
161
167
  # @param options [Hash] the options to send with the message
162
168
  def patch_signals(signals, options = BLANK_OPTIONS)
163
- stream_no_heartbeat do |sse|
169
+ stream(heartbeat: false) do |sse|
164
170
  sse.patch_signals(signals, options)
165
171
  end
166
172
  end
@@ -174,7 +180,7 @@ module Datastar
174
180
  # @param paths [Array<String>] object paths to the signals to remove
175
181
  # @param options [Hash] the options to send with the message
176
182
  def remove_signals(paths, options = BLANK_OPTIONS)
177
- stream_no_heartbeat do |sse|
183
+ stream(heartbeat: false) do |sse|
178
184
  sse.remove_signals(paths, options)
179
185
  end
180
186
  end
@@ -188,7 +194,7 @@ module Datastar
188
194
  # @param script [String] the script to execute
189
195
  # @param options [Hash] the options to send with the message
190
196
  def execute_script(script, options = BLANK_OPTIONS)
191
- stream_no_heartbeat do |sse|
197
+ stream(heartbeat: false) do |sse|
192
198
  sse.execute_script(script, options)
193
199
  end
194
200
  end
@@ -198,7 +204,7 @@ module Datastar
198
204
  #
199
205
  # @param url [String] the URL or path to redirect to
200
206
  def redirect(url)
201
- stream_no_heartbeat do |sse|
207
+ stream(heartbeat: false) do |sse|
202
208
  sse.redirect(url)
203
209
  end
204
210
  end
@@ -239,10 +245,24 @@ module Datastar
239
245
  # By default, the built-in Rack finalzer just returns the resposne Array which can be used by any Rack handler.
240
246
  # On Rails, the Rails controller response is set to this objects streaming response.
241
247
  #
248
+ # A per-call +heartbeat:+ keyword overrides the constructor-level heartbeat
249
+ # for the duration of this call. Pass +false+ to disable heartbeat for a
250
+ # one-shot message (e.g. a single +patch_elements+), or a Numeric interval
251
+ # to enable it. The previous value is restored once the call returns.
252
+ # @example Disable heartbeat for a single response
253
+ #
254
+ # datastar.stream(heartbeat: false) do |sse|
255
+ # sse.patch_elements(html)
256
+ # end
257
+ #
242
258
  # @param streamer [#call(ServerSentEventGenerator), nil] a callable to call with the generator
259
+ # @param heartbeat [Numeric, false] override the heartbeat interval for this call, or +false+ to disable
243
260
  # @yieldparam sse [ServerSentEventGenerator] the generator object
244
261
  # @return [Object] depends on the finalize callback
245
- def stream(streamer = nil, &block)
262
+ def stream(streamer = nil, heartbeat: @heartbeat, &block)
263
+ heartbeat_was = @heartbeat
264
+ @heartbeat = heartbeat
265
+
246
266
  streamer ||= block
247
267
  @streamers << streamer
248
268
  if @heartbeat && !@heartbeat_on
@@ -263,18 +283,12 @@ module Datastar
263
283
 
264
284
  @response.body = body
265
285
  @finalize.call(@view_context, @response)
286
+ ensure
287
+ @heartbeat = heartbeat_was
266
288
  end
267
289
 
268
290
  private
269
291
 
270
- def stream_no_heartbeat(&block)
271
- was = @heartbeat
272
- @heartbeat = false
273
- stream(&block).tap do
274
- @heartbeat = was
275
- end
276
- end
277
-
278
292
  # Produce a response body for a single stream
279
293
  # In this case, the SSE generator can write directly to the socket
280
294
  #
@@ -283,6 +297,7 @@ module Datastar
283
297
  # @api private
284
298
  def stream_one(streamer)
285
299
  proc do |socket|
300
+ socket = wrap_socket(socket)
286
301
  generator = ServerSentEventGenerator.new(socket, signals:, view_context: @view_context)
287
302
  @on_connect.each { |callable| callable.call(generator) }
288
303
  handling_sync_errors(generator, socket) do
@@ -308,6 +323,7 @@ module Datastar
308
323
  @queue ||= @executor.new_queue
309
324
 
310
325
  proc do |socket|
326
+ socket = wrap_socket(socket)
311
327
  signs = signals
312
328
  conn_generator = ServerSentEventGenerator.new(socket, signals: signs, view_context: @view_context)
313
329
  @on_connect.each { |callable| callable.call(conn_generator) }
@@ -360,6 +376,13 @@ module Datastar
360
376
  end
361
377
  end
362
378
 
379
+ # Wrap socket in a CompressedSocket if compression is negotiated
380
+ # @param socket [IO]
381
+ # @return [CompressedSocket, IO]
382
+ def wrap_socket(socket)
383
+ @compressor.wrap_socket(socket)
384
+ end
385
+
363
386
  # Handle errors caught during streaming
364
387
  # @param error [Exception] the error that occurred
365
388
  # @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.3'
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.3
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