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 +4 -4
- data/README.md +96 -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 +16 -1
- 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: 885e73382637f598ee1feeea492556e30f4dea1dcdd9eae1627649a8dbadc0bc
|
|
4
|
+
data.tar.gz: 2945bedd152787c39ae3843f2cf2c937bdba3d7d8a0ea0f6b9a4d0ac2bf34a18
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 [
|
|
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
|
|
@@ -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
|
-
#
|
|
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)
|
data/lib/datastar/dispatcher.rb
CHANGED
|
@@ -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' =>
|
|
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.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.
|
|
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
|