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 +4 -4
- data/README.md +111 -10
- data/benchmarks/compression.rb +251 -0
- data/lib/datastar/compression_config.rb +167 -0
- data/lib/datastar/compressor/brotli.rb +56 -0
- data/lib/datastar/compressor/gzip.rb +60 -0
- data/lib/datastar/configuration.rb +6 -0
- data/lib/datastar/dispatcher.rb +40 -17
- data/lib/datastar/server_sent_event_generator.rb +49 -10
- data/lib/datastar/version.rb +1 -1
- data/lib/datastar.rb +1 -2
- metadata +8 -5
- data/lib/datastar/consts.rb +0 -57
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 568e2026e9657b4042957c360641bc3c1b51bfdfe7dcf1710a78b75e07a71ccf
|
|
4
|
+
data.tar.gz: 25851c3fc4b6bb74c8db43d6ecfbbd8fa796be259fe9b0b4bc4653e3077c0e41
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 [
|
|
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',
|
|
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
|
-
#
|
|
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)
|
data/lib/datastar/dispatcher.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' =>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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: #{
|
|
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
|
-
|
|
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,
|
|
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[
|
|
105
|
-
options[
|
|
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 ==
|
|
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"
|
data/lib/datastar/version.rb
CHANGED
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.
|
|
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.
|
|
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.
|
|
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:
|
|
120
|
+
rubygems_version: 4.0.8
|
|
118
121
|
specification_version: 4
|
|
119
122
|
summary: Ruby SDK for Datastar. Rack-compatible.
|
|
120
123
|
test_files: []
|
data/lib/datastar/consts.rb
DELETED
|
@@ -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
|