datastar 1.0.0 → 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/LICENSE.md +4 -16
- data/README.md +101 -14
- data/benchmarks/compression.rb +251 -0
- data/examples/hello-world/Gemfile +1 -1
- data/examples/hello-world/Gemfile.lock +3 -3
- data/examples/hello-world/hello-world.html +5 -5
- data/examples/progress/progress.ru +307 -0
- data/examples/threads/Gemfile +1 -1
- data/examples/threads/Gemfile.lock +3 -3
- data/examples/threads/threads.ru +3 -3
- data/lib/datastar/async_executor.rb +3 -1
- 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 +54 -11
- data/lib/datastar/server_sent_event_generator.rb +52 -10
- data/lib/datastar/version.rb +1 -1
- data/lib/datastar.rb +1 -2
- metadata +9 -5
- data/lib/datastar/consts.rb +0 -57
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
require 'bundler'
|
|
2
|
+
Bundler.setup(:test)
|
|
3
|
+
|
|
4
|
+
require 'datastar'
|
|
5
|
+
|
|
6
|
+
# This is a demo Rack app to showcase patching components and signals
|
|
7
|
+
# from the server to the client.
|
|
8
|
+
# To run:
|
|
9
|
+
#
|
|
10
|
+
# # install dependencies
|
|
11
|
+
# bundle install
|
|
12
|
+
# # run this endpoint with Puma server
|
|
13
|
+
# bundle exec puma ./progress.ru
|
|
14
|
+
#
|
|
15
|
+
# Then open http://localhost:9292
|
|
16
|
+
#
|
|
17
|
+
# A Web Component for circular progress
|
|
18
|
+
# Progress is controlled by a `progress` signal
|
|
19
|
+
PROGRESS = <<~JAVASCRIPT
|
|
20
|
+
class CircularProgress extends HTMLElement {
|
|
21
|
+
constructor() {
|
|
22
|
+
super();
|
|
23
|
+
this.attachShadow({ mode: 'open' });
|
|
24
|
+
this._progress = 0;
|
|
25
|
+
this.radius = 90;
|
|
26
|
+
this.circumference = 2 * Math.PI * this.radius;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static get observedAttributes() {
|
|
30
|
+
return ['progress'];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
get progress() {
|
|
34
|
+
return this._progress;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
38
|
+
if (name === 'progress' && oldValue !== newValue) {
|
|
39
|
+
this._progress = Math.max(0, Math.min(100, parseFloat(newValue) || 0));
|
|
40
|
+
this.updateProgress();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
connectedCallback() {
|
|
45
|
+
this.render();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
render() {
|
|
49
|
+
this.shadowRoot.innerHTML = `
|
|
50
|
+
<slot></slot>
|
|
51
|
+
<svg
|
|
52
|
+
width="200"
|
|
53
|
+
height="200"
|
|
54
|
+
viewBox="-25 -25 250 250"
|
|
55
|
+
style="transform: rotate(-90deg)"
|
|
56
|
+
>
|
|
57
|
+
<!-- Background circle -->
|
|
58
|
+
<circle
|
|
59
|
+
r="${this.radius}"
|
|
60
|
+
cx="100"
|
|
61
|
+
cy="100"
|
|
62
|
+
fill="transparent"
|
|
63
|
+
stroke="#e0e0e0"
|
|
64
|
+
stroke-width="16px"
|
|
65
|
+
stroke-dasharray="${this.circumference}px"
|
|
66
|
+
stroke-dashoffset="${this.circumference}px"
|
|
67
|
+
></circle>
|
|
68
|
+
|
|
69
|
+
<!-- Progress circle -->
|
|
70
|
+
<circle
|
|
71
|
+
id="progress-circle"
|
|
72
|
+
r="${this.radius}"
|
|
73
|
+
cx="100"
|
|
74
|
+
cy="100"
|
|
75
|
+
fill="transparent"
|
|
76
|
+
stroke="#6bdba7"
|
|
77
|
+
stroke-width="16px"
|
|
78
|
+
stroke-linecap="round"
|
|
79
|
+
stroke-dasharray="${this.circumference}px"
|
|
80
|
+
style="transition: stroke-dashoffset 0.1s ease-in-out"
|
|
81
|
+
></circle>
|
|
82
|
+
|
|
83
|
+
<!-- Progress text -->
|
|
84
|
+
<text
|
|
85
|
+
id="progress-text"
|
|
86
|
+
x="44px"
|
|
87
|
+
y="115px"
|
|
88
|
+
fill="#6bdba7"
|
|
89
|
+
font-size="52px"
|
|
90
|
+
font-weight="bold"
|
|
91
|
+
style="transform:rotate(90deg) translate(0px, -196px)"
|
|
92
|
+
></text>
|
|
93
|
+
</svg>
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
updateProgress() {
|
|
98
|
+
if (!this.shadowRoot) return;
|
|
99
|
+
|
|
100
|
+
const progressCircle = this.shadowRoot.getElementById('progress-circle');
|
|
101
|
+
const progressText = this.shadowRoot.getElementById('progress-text');
|
|
102
|
+
|
|
103
|
+
if (progressCircle && progressText) {
|
|
104
|
+
// Calculate stroke-dashoffset based on progress
|
|
105
|
+
const offset = this.circumference - (this._progress / 100) * this.circumference;
|
|
106
|
+
progressCircle.style.strokeDashoffset = `${offset}px`;
|
|
107
|
+
|
|
108
|
+
// Update text
|
|
109
|
+
progressText.textContent = `${Math.round(this._progress)}%`;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Register the custom element
|
|
115
|
+
customElements.define('circular-progress', CircularProgress);
|
|
116
|
+
JAVASCRIPT
|
|
117
|
+
|
|
118
|
+
# The initial index HTML page
|
|
119
|
+
INDEX = <<~HTML
|
|
120
|
+
<!DOCTYPE html>
|
|
121
|
+
<html>
|
|
122
|
+
<head>
|
|
123
|
+
<meta charset="UTF-8">
|
|
124
|
+
<title>Datastar progress-circle</title>
|
|
125
|
+
<style>
|
|
126
|
+
body {
|
|
127
|
+
font-family: Arial, sans-serif;
|
|
128
|
+
padding: 20px;
|
|
129
|
+
background-color: #f5f5f5;
|
|
130
|
+
}
|
|
131
|
+
.demo-container {
|
|
132
|
+
max-width: 800px;
|
|
133
|
+
margin: 0 auto;
|
|
134
|
+
background: white;
|
|
135
|
+
padding: 30px;
|
|
136
|
+
border-radius: 10px;
|
|
137
|
+
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
138
|
+
}
|
|
139
|
+
button {
|
|
140
|
+
background: linear-gradient(135deg, #6bdba7 0%, #5bc399 100%);
|
|
141
|
+
color: white;
|
|
142
|
+
border: none;
|
|
143
|
+
padding: 12px 24px;
|
|
144
|
+
font-size: 16px;
|
|
145
|
+
font-weight: 600;
|
|
146
|
+
border-radius: 8px;
|
|
147
|
+
cursor: pointer;
|
|
148
|
+
transition: all 0.2s ease;
|
|
149
|
+
box-shadow: 0 2px 4px rgba(107, 219, 167, 0.3);
|
|
150
|
+
margin-bottom: 20px;
|
|
151
|
+
}
|
|
152
|
+
button:hover:not([aria-disabled="true"]) {
|
|
153
|
+
background: linear-gradient(135deg, #5bc399 0%, #4db389 100%);
|
|
154
|
+
transform: translateY(-1px);
|
|
155
|
+
box-shadow: 0 4px 8px rgba(107, 219, 167, 0.4);
|
|
156
|
+
}
|
|
157
|
+
button:active:not([aria-disabled="true"]) {
|
|
158
|
+
transform: translateY(0);
|
|
159
|
+
box-shadow: 0 2px 4px rgba(107, 219, 167, 0.3);
|
|
160
|
+
}
|
|
161
|
+
button[aria-disabled="true"] {
|
|
162
|
+
background: #e0e0e0;
|
|
163
|
+
color: #999;
|
|
164
|
+
cursor: not-allowed;
|
|
165
|
+
box-shadow: none;
|
|
166
|
+
}
|
|
167
|
+
.col {
|
|
168
|
+
flex: 1;
|
|
169
|
+
padding: 0 15px;
|
|
170
|
+
min-height: 340px;
|
|
171
|
+
}
|
|
172
|
+
.col:first-child {
|
|
173
|
+
padding-left: 0;
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-direction: column;
|
|
176
|
+
align-items: center;
|
|
177
|
+
justify-content: center;
|
|
178
|
+
}
|
|
179
|
+
.col:last-child {
|
|
180
|
+
padding-right: 0;
|
|
181
|
+
}
|
|
182
|
+
@media (min-width: 768px) {
|
|
183
|
+
.demo-container {
|
|
184
|
+
display: flex;
|
|
185
|
+
gap: 30px;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
#activity {
|
|
189
|
+
overflow-y: auto;
|
|
190
|
+
border: 1px solid #e0e0e0;
|
|
191
|
+
border-radius: 8px;
|
|
192
|
+
padding: 16px;
|
|
193
|
+
background: #fafafa;
|
|
194
|
+
}
|
|
195
|
+
.a-item {
|
|
196
|
+
background: white;
|
|
197
|
+
border: 1px solid #e8e8e8;
|
|
198
|
+
border-radius: 6px;
|
|
199
|
+
padding: 12px 16px;
|
|
200
|
+
margin-bottom: 8px;
|
|
201
|
+
font-size: 14px;
|
|
202
|
+
color: #333;
|
|
203
|
+
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
|
204
|
+
transition: all 0.2s ease;
|
|
205
|
+
}
|
|
206
|
+
.a-item:last-child {
|
|
207
|
+
margin-bottom: 0;
|
|
208
|
+
}
|
|
209
|
+
.a-item:hover {
|
|
210
|
+
background: #f8f9fa;
|
|
211
|
+
border-color: #d0d0d0;
|
|
212
|
+
}
|
|
213
|
+
.a-item .time {
|
|
214
|
+
display: block;
|
|
215
|
+
font-size: 11px;
|
|
216
|
+
color: #888;
|
|
217
|
+
margin-bottom: 4px;
|
|
218
|
+
font-family: monospace;
|
|
219
|
+
}
|
|
220
|
+
.a-item.done {
|
|
221
|
+
background: #f0f9f4;
|
|
222
|
+
border-color: #6bdba7;
|
|
223
|
+
color: #2d5a3d;
|
|
224
|
+
}
|
|
225
|
+
.a-item.done .time {
|
|
226
|
+
color: #5a8a6a;
|
|
227
|
+
}
|
|
228
|
+
#title {
|
|
229
|
+
text-align: center;
|
|
230
|
+
}
|
|
231
|
+
</style>
|
|
232
|
+
<script type="module">#{PROGRESS}</script>
|
|
233
|
+
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
|
|
234
|
+
</head>
|
|
235
|
+
<body>
|
|
236
|
+
<div class="demo-container">
|
|
237
|
+
<div class="col">
|
|
238
|
+
<p>
|
|
239
|
+
<button
|
|
240
|
+
data-indicator:_fetching
|
|
241
|
+
data-on:click="!$_fetching && @get('/', {openWhenHidden: true})"
|
|
242
|
+
data-attr:aria-disabled="`${$_fetching}`"
|
|
243
|
+
>Start</button>
|
|
244
|
+
</p>
|
|
245
|
+
<div id="work">
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div class="col" id="activity">
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
</body>
|
|
253
|
+
<html>
|
|
254
|
+
HTML
|
|
255
|
+
|
|
256
|
+
trap('INT') { exit }
|
|
257
|
+
|
|
258
|
+
# The server-side app
|
|
259
|
+
# It handles the initial page load and serves the initial HTML.
|
|
260
|
+
# It also handles Datastar SSE requests and streams updates to the client.
|
|
261
|
+
run do |env|
|
|
262
|
+
datastar = Datastar
|
|
263
|
+
.from_rack_env(env)
|
|
264
|
+
.on_connect do |socket|
|
|
265
|
+
p ['connect', socket]
|
|
266
|
+
end.on_server_disconnect do |socket|
|
|
267
|
+
p ['server disconnect', socket]
|
|
268
|
+
end.on_client_disconnect do |socket|
|
|
269
|
+
p ['client disconnect', socket]
|
|
270
|
+
end.on_error do |error|
|
|
271
|
+
p ['exception', error]
|
|
272
|
+
puts error.backtrace.join("\n")
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
if datastar.sse? # <= we're in a Datastar SSE request
|
|
276
|
+
|
|
277
|
+
# A thread to simulate the work and control the progress component
|
|
278
|
+
datastar.stream do |sse|
|
|
279
|
+
# Reset activity
|
|
280
|
+
sse.patch_elements(%(<div id="activity" class="col"></div>))
|
|
281
|
+
|
|
282
|
+
# step 1: add the initial progress component to the DOM
|
|
283
|
+
sse.patch_elements(%(<circular-progress id="work" data-bind:progress data-attr:progress="$progress"><h1 id="title">Processing...</h1></circular-progress>))
|
|
284
|
+
|
|
285
|
+
# step 2: simulate work and update the progress signal
|
|
286
|
+
0.upto(100) do |i|
|
|
287
|
+
sleep rand(0.03..0.09) # Simulate work
|
|
288
|
+
sse.patch_signals(progress: i)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# step 3: update the DOM to indicate completion
|
|
292
|
+
# sse.patch_elements(%(<p id="work">Done!</p>))
|
|
293
|
+
sse.patch_elements(%(<div class="a-item done"><span class="time">#{Time.now.iso8601}</span>Done!</div>), selector: '#activity', mode: 'append')
|
|
294
|
+
sse.patch_elements(%(<h1 id="title">Done!</h1>))
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# A second thread to push activity updates to the UI
|
|
298
|
+
datastar.stream do |sse|
|
|
299
|
+
['Work started', 'Connecting to API', 'downloading data', 'processing data'].each do |activity|
|
|
300
|
+
sse.patch_elements(%(<div class="a-item"><span class="time">#{Time.now.iso8601}</span>#{activity}</div>), selector: '#activity', mode: 'append')
|
|
301
|
+
sleep rand(0.5..1.7) # Simulate time taken for each activity
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
else # <= We're in a regular HTTP request
|
|
305
|
+
[200, { 'content-type' => 'text/html' }, [INDEX]]
|
|
306
|
+
end
|
|
307
|
+
end
|
data/examples/threads/Gemfile
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
|
-
remote:
|
|
2
|
+
remote: ../../..
|
|
3
3
|
specs:
|
|
4
|
-
datastar (1.0.0
|
|
4
|
+
datastar (1.0.0)
|
|
5
5
|
json
|
|
6
6
|
logger
|
|
7
7
|
rack (>= 3.1.14)
|
|
@@ -9,7 +9,7 @@ PATH
|
|
|
9
9
|
GEM
|
|
10
10
|
remote: https://rubygems.org/
|
|
11
11
|
specs:
|
|
12
|
-
json (2.
|
|
12
|
+
json (2.16.0)
|
|
13
13
|
logger (1.7.0)
|
|
14
14
|
nio4r (2.7.4)
|
|
15
15
|
puma (6.6.0)
|
data/examples/threads/threads.ru
CHANGED
|
@@ -26,12 +26,12 @@ INDEX = <<~HTML
|
|
|
26
26
|
span { font-weight: bold; }
|
|
27
27
|
}
|
|
28
28
|
</style>
|
|
29
|
-
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@
|
|
29
|
+
<script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@1.0.0-RC.6/bundles/datastar.js"></script>
|
|
30
30
|
</head>
|
|
31
31
|
<body>
|
|
32
32
|
<button#{' '}
|
|
33
|
-
data-on
|
|
34
|
-
data-indicator
|
|
33
|
+
data-on:click="@get('/')"#{' '}
|
|
34
|
+
data-indicator:heartbeat#{' '}
|
|
35
35
|
>Start</button>
|
|
36
36
|
<p class="counter">Slow thread: <span id="slow">waiting</span></p>
|
|
37
37
|
<p class="counter">Fast thread: <span id="fast">waiting</span></p>
|
|
@@ -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)
|