ld-eventsource 1.0.0
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 +7 -0
- data/.circleci/config.yml +90 -0
- data/.gitignore +15 -0
- data/CHANGELOG.md +7 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +47 -0
- data/LICENSE +13 -0
- data/README.md +45 -0
- data/Rakefile +5 -0
- data/ld-eventsource.gemspec +31 -0
- data/lib/ld-eventsource.rb +14 -0
- data/lib/ld-eventsource/client.rb +296 -0
- data/lib/ld-eventsource/errors.rb +67 -0
- data/lib/ld-eventsource/events.rb +16 -0
- data/lib/ld-eventsource/impl/backoff.rb +60 -0
- data/lib/ld-eventsource/impl/event_parser.rb +81 -0
- data/lib/ld-eventsource/impl/streaming_http.rb +222 -0
- data/lib/ld-eventsource/version.rb +3 -0
- data/scripts/gendocs.sh +12 -0
- data/scripts/release.sh +30 -0
- data/spec/client_spec.rb +346 -0
- data/spec/event_parser_spec.rb +100 -0
- data/spec/http_stub.rb +81 -0
- data/spec/streaming_http_spec.rb +263 -0
- metadata +169 -0
data/scripts/gendocs.sh
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
#!/bin/bash
|
2
|
+
|
3
|
+
# Use this script to generate documentation locally in ./doc so it can be proofed before release.
|
4
|
+
# After release, documentation will be visible at https://www.rubydoc.info/gems/ld-eventsource
|
5
|
+
|
6
|
+
gem install --conservative yard
|
7
|
+
gem install --conservative redcarpet # provides Markdown formatting
|
8
|
+
|
9
|
+
# yard doesn't seem to do recursive directories, even though Ruby's Dir.glob supposedly recurses for "**"
|
10
|
+
PATHS="lib/*.rb lib/**/*.rb lib/**/**/*.rb"
|
11
|
+
|
12
|
+
yard doc --no-private --markup markdown --markup-provider redcarpet --embed-mixins $PATHS - README.md
|
data/scripts/release.sh
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
#!/usr/bin/env bash
|
2
|
+
|
3
|
+
# This script updates the version for the library and releases it to RubyGems
|
4
|
+
# It will only work if you have the proper credentials set up in ~/.gem/credentials
|
5
|
+
|
6
|
+
# It takes exactly one argument: the new version.
|
7
|
+
# It should be run from the root of this git repo like this:
|
8
|
+
# ./scripts/release.sh 4.0.9
|
9
|
+
|
10
|
+
# When done you should commit and push the changes made.
|
11
|
+
|
12
|
+
set -uxe
|
13
|
+
|
14
|
+
VERSION=$1
|
15
|
+
GEM_NAME=ld-eventsource
|
16
|
+
|
17
|
+
echo "Starting $GEM_NAME release."
|
18
|
+
|
19
|
+
# Update version in version.rb
|
20
|
+
VERSION_RB_TEMP=./version.rb.tmp
|
21
|
+
sed "s/VERSION =.*/VERSION = \"${VERSION}\"/g" lib/$GEM_NAME/version.rb > ${VERSION_RB_TEMP}
|
22
|
+
mv ${VERSION_RB_TEMP} lib/$GEM_NAME/version.rb
|
23
|
+
|
24
|
+
# Build Ruby gem
|
25
|
+
gem build $GEM_NAME.gemspec
|
26
|
+
|
27
|
+
# Publish Ruby gem
|
28
|
+
gem push $GEM_NAME-${VERSION}.gem
|
29
|
+
|
30
|
+
echo "Done with $GEM_NAME release"
|
data/spec/client_spec.rb
ADDED
@@ -0,0 +1,346 @@
|
|
1
|
+
require "ld-eventsource"
|
2
|
+
require "socketry"
|
3
|
+
require "http_stub"
|
4
|
+
|
5
|
+
#
|
6
|
+
# End-to-end tests of the SSE client against a real server
|
7
|
+
#
|
8
|
+
describe SSE::Client do
|
9
|
+
subject { SSE::Client }
|
10
|
+
|
11
|
+
let(:simple_event_1) { SSE::StreamEvent.new(:go, "foo", "a")}
|
12
|
+
let(:simple_event_2) { SSE::StreamEvent.new(:stop, "bar", "b")}
|
13
|
+
let(:simple_event_1_text) { <<-EOT
|
14
|
+
event: go
|
15
|
+
data: foo
|
16
|
+
id: a
|
17
|
+
|
18
|
+
EOT
|
19
|
+
}
|
20
|
+
let(:simple_event_2_text) { <<-EOT
|
21
|
+
event: stop
|
22
|
+
data: bar
|
23
|
+
id: b
|
24
|
+
|
25
|
+
EOT
|
26
|
+
}
|
27
|
+
let(:reconnect_asap) { 0.01 }
|
28
|
+
|
29
|
+
def with_client(client)
|
30
|
+
begin
|
31
|
+
yield client
|
32
|
+
ensure
|
33
|
+
client.close
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def send_stream_content(res, content, keep_open:)
|
38
|
+
res.content_type = "text/event-stream"
|
39
|
+
res.status = 200
|
40
|
+
res.chunked = true
|
41
|
+
rd, wr = IO.pipe
|
42
|
+
wr.write(content)
|
43
|
+
res.body = rd
|
44
|
+
if !keep_open
|
45
|
+
wr.close
|
46
|
+
end
|
47
|
+
wr
|
48
|
+
end
|
49
|
+
|
50
|
+
it "sends expected headers" do
|
51
|
+
with_server do |server|
|
52
|
+
requests = Queue.new
|
53
|
+
server.setup_response("/") do |req,res|
|
54
|
+
requests << req
|
55
|
+
send_stream_content(res, "", keep_open: true)
|
56
|
+
end
|
57
|
+
|
58
|
+
headers = { "Authorization" => "secret" }
|
59
|
+
|
60
|
+
with_client(subject.new(server.base_uri, headers: headers)) do |client|
|
61
|
+
received_req = requests.pop
|
62
|
+
expect(received_req.header).to eq({
|
63
|
+
"accept" => ["text/event-stream"],
|
64
|
+
"cache-control" => ["no-cache"],
|
65
|
+
"host" => ["127.0.0.1"],
|
66
|
+
"authorization" => ["secret"]
|
67
|
+
})
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
it "sends initial Last-Event-Id if specified" do
|
73
|
+
id = "xyz"
|
74
|
+
with_server do |server|
|
75
|
+
requests = Queue.new
|
76
|
+
server.setup_response("/") do |req,res|
|
77
|
+
requests << req
|
78
|
+
send_stream_content(res, "", keep_open: true)
|
79
|
+
end
|
80
|
+
|
81
|
+
headers = { "Authorization" => "secret" }
|
82
|
+
|
83
|
+
with_client(subject.new(server.base_uri, headers: headers, last_event_id: id)) do |client|
|
84
|
+
received_req = requests.pop
|
85
|
+
expect(received_req.header).to eq({
|
86
|
+
"accept" => ["text/event-stream"],
|
87
|
+
"cache-control" => ["no-cache"],
|
88
|
+
"host" => ["127.0.0.1"],
|
89
|
+
"authorization" => ["secret"],
|
90
|
+
"last-event-id" => [id]
|
91
|
+
})
|
92
|
+
end
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
it "receives messages" do
|
97
|
+
events_body = simple_event_1_text + simple_event_2_text
|
98
|
+
with_server do |server|
|
99
|
+
server.setup_response("/") do |req,res|
|
100
|
+
send_stream_content(res, events_body, keep_open: true)
|
101
|
+
end
|
102
|
+
|
103
|
+
event_sink = Queue.new
|
104
|
+
client = subject.new(server.base_uri) do |c|
|
105
|
+
c.on_event { |event| event_sink << event }
|
106
|
+
end
|
107
|
+
|
108
|
+
with_client(client) do |client|
|
109
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
110
|
+
expect(event_sink.pop).to eq(simple_event_2)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
it "reconnects after error response" do
|
116
|
+
events_body = simple_event_1_text
|
117
|
+
with_server do |server|
|
118
|
+
attempt = 0
|
119
|
+
server.setup_response("/") do |req,res|
|
120
|
+
attempt += 1
|
121
|
+
if attempt == 1
|
122
|
+
res.status = 500
|
123
|
+
res.body = "sorry"
|
124
|
+
res.keep_alive = false
|
125
|
+
else
|
126
|
+
send_stream_content(res, events_body, keep_open: true)
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
event_sink = Queue.new
|
131
|
+
error_sink = Queue.new
|
132
|
+
client = subject.new(server.base_uri, reconnect_time: reconnect_asap) do |c|
|
133
|
+
c.on_event { |event| event_sink << event }
|
134
|
+
c.on_error { |error| error_sink << error }
|
135
|
+
end
|
136
|
+
|
137
|
+
with_client(client) do |client|
|
138
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
139
|
+
expect(error_sink.pop).to eq(SSE::Errors::HTTPStatusError.new(500, "sorry"))
|
140
|
+
expect(attempt).to eq 2
|
141
|
+
end
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
it "reconnects after invalid content type" do
|
146
|
+
events_body = simple_event_1_text
|
147
|
+
with_server do |server|
|
148
|
+
attempt = 0
|
149
|
+
server.setup_response("/") do |req,res|
|
150
|
+
attempt += 1
|
151
|
+
if attempt == 1
|
152
|
+
res.status = 200
|
153
|
+
res.content_type = "text/plain"
|
154
|
+
res.body = "sorry"
|
155
|
+
res.keep_alive = false
|
156
|
+
else
|
157
|
+
send_stream_content(res, events_body, keep_open: true)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
event_sink = Queue.new
|
162
|
+
error_sink = Queue.new
|
163
|
+
client = subject.new(server.base_uri, reconnect_time: reconnect_asap) do |c|
|
164
|
+
c.on_event { |event| event_sink << event }
|
165
|
+
c.on_error { |error| error_sink << error }
|
166
|
+
end
|
167
|
+
|
168
|
+
with_client(client) do |client|
|
169
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
170
|
+
expect(error_sink.pop).to eq(SSE::Errors::HTTPContentTypeError.new("text/plain"))
|
171
|
+
expect(attempt).to eq 2
|
172
|
+
end
|
173
|
+
end
|
174
|
+
end
|
175
|
+
|
176
|
+
it "reconnects after read timeout" do
|
177
|
+
events_body = simple_event_1_text
|
178
|
+
with_server do |server|
|
179
|
+
attempt = 0
|
180
|
+
server.setup_response("/") do |req,res|
|
181
|
+
attempt += 1
|
182
|
+
if attempt == 1
|
183
|
+
sleep(1)
|
184
|
+
end
|
185
|
+
send_stream_content(res, events_body, keep_open: true)
|
186
|
+
end
|
187
|
+
|
188
|
+
event_sink = Queue.new
|
189
|
+
client = subject.new(server.base_uri, reconnect_time: reconnect_asap, read_timeout: 0.25) do |c|
|
190
|
+
c.on_event { |event| event_sink << event }
|
191
|
+
end
|
192
|
+
|
193
|
+
with_client(client) do |client|
|
194
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
195
|
+
expect(attempt).to eq 2
|
196
|
+
end
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
it "reconnects if stream returns EOF" do
|
201
|
+
with_server do |server|
|
202
|
+
attempt = 0
|
203
|
+
server.setup_response("/") do |req,res|
|
204
|
+
attempt += 1
|
205
|
+
send_stream_content(res, attempt == 1 ? simple_event_1_text : simple_event_2_text,
|
206
|
+
keep_open: attempt == 2)
|
207
|
+
end
|
208
|
+
|
209
|
+
event_sink = Queue.new
|
210
|
+
client = subject.new(server.base_uri, reconnect_time: reconnect_asap) do |c|
|
211
|
+
c.on_event { |event| event_sink << event }
|
212
|
+
end
|
213
|
+
|
214
|
+
with_client(client) do |client|
|
215
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
216
|
+
expect(event_sink.pop).to eq(simple_event_2)
|
217
|
+
expect(attempt).to eq 2
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
|
222
|
+
it "sends ID of last received event, if any, when reconnecting" do
|
223
|
+
with_server do |server|
|
224
|
+
requests = Queue.new
|
225
|
+
attempt = 0
|
226
|
+
server.setup_response("/") do |req,res|
|
227
|
+
requests << req
|
228
|
+
attempt += 1
|
229
|
+
send_stream_content(res, attempt == 1 ? simple_event_1_text : simple_event_2_text,
|
230
|
+
keep_open: attempt == 2)
|
231
|
+
end
|
232
|
+
|
233
|
+
event_sink = Queue.new
|
234
|
+
client = subject.new(server.base_uri, reconnect_time: reconnect_asap) do |c|
|
235
|
+
c.on_event { |event| event_sink << event }
|
236
|
+
end
|
237
|
+
|
238
|
+
with_client(client) do |client|
|
239
|
+
req1 = requests.pop
|
240
|
+
req2 = requests.pop
|
241
|
+
expect(req2.header["last-event-id"]).to eq([ simple_event_1.id ])
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
it "increases backoff delay if a failure happens within the reset threshold" do
|
247
|
+
request_times = []
|
248
|
+
max_requests = 5
|
249
|
+
initial_interval = 0.25
|
250
|
+
|
251
|
+
with_server do |server|
|
252
|
+
attempt = 0
|
253
|
+
server.setup_response("/") do |req,res|
|
254
|
+
request_times << Time.now
|
255
|
+
attempt += 1
|
256
|
+
send_stream_content(res, simple_event_1_text, keep_open: attempt == max_requests)
|
257
|
+
end
|
258
|
+
|
259
|
+
event_sink = Queue.new
|
260
|
+
client = subject.new(server.base_uri, reconnect_time: initial_interval) do |c|
|
261
|
+
c.on_event { |event| event_sink << event }
|
262
|
+
end
|
263
|
+
|
264
|
+
with_client(client) do |client|
|
265
|
+
last_interval = nil
|
266
|
+
max_requests.times do |i|
|
267
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
268
|
+
if i > 0
|
269
|
+
interval = request_times[i] - request_times[i - 1]
|
270
|
+
minimum_expected_interval = initial_interval * (2 ** (i - 1)) / 2
|
271
|
+
expect(interval).to be >= minimum_expected_interval
|
272
|
+
last_interval = interval
|
273
|
+
end
|
274
|
+
end
|
275
|
+
end
|
276
|
+
end
|
277
|
+
end
|
278
|
+
|
279
|
+
it "resets backoff delay if a failure happens after the reset threshold" do
|
280
|
+
request_times = []
|
281
|
+
request_end_times = []
|
282
|
+
max_requests = 5
|
283
|
+
threshold = 0.3
|
284
|
+
initial_interval = 0.25
|
285
|
+
|
286
|
+
with_server do |server|
|
287
|
+
attempt = 0
|
288
|
+
server.setup_response("/") do |req,res|
|
289
|
+
request_times << Time.now
|
290
|
+
attempt += 1
|
291
|
+
stream = send_stream_content(res, simple_event_1_text, keep_open: true)
|
292
|
+
Thread.new do
|
293
|
+
sleep(threshold + 0.01)
|
294
|
+
stream.close
|
295
|
+
request_end_times << Time.now
|
296
|
+
end
|
297
|
+
end
|
298
|
+
|
299
|
+
event_sink = Queue.new
|
300
|
+
client = subject.new(server.base_uri, reconnect_time: initial_interval, reconnect_reset_interval: threshold) do |c|
|
301
|
+
c.on_event { |event| event_sink << event }
|
302
|
+
end
|
303
|
+
|
304
|
+
with_client(client) do |client|
|
305
|
+
last_interval = nil
|
306
|
+
max_requests.times do |i|
|
307
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
308
|
+
if i > 0
|
309
|
+
interval = request_times[i] - request_end_times[i - 1]
|
310
|
+
expect(interval).to be <= initial_interval
|
311
|
+
end
|
312
|
+
end
|
313
|
+
end
|
314
|
+
end
|
315
|
+
end
|
316
|
+
|
317
|
+
it "can change initial reconnect delay based on directive from server" do
|
318
|
+
request_times = []
|
319
|
+
configured_interval = 1
|
320
|
+
retry_ms = 100
|
321
|
+
|
322
|
+
with_server do |server|
|
323
|
+
attempt = 0
|
324
|
+
server.setup_response("/") do |req,res|
|
325
|
+
request_times << Time.now
|
326
|
+
attempt += 1
|
327
|
+
if attempt == 1
|
328
|
+
send_stream_content(res, "retry: #{retry_ms}\n", keep_open: false)
|
329
|
+
else
|
330
|
+
send_stream_content(res, simple_event_1_text, keep_open: true)
|
331
|
+
end
|
332
|
+
end
|
333
|
+
|
334
|
+
event_sink = Queue.new
|
335
|
+
client = subject.new(server.base_uri, reconnect_time: configured_interval) do |c|
|
336
|
+
c.on_event { |event| event_sink << event }
|
337
|
+
end
|
338
|
+
|
339
|
+
with_client(client) do |client|
|
340
|
+
expect(event_sink.pop).to eq(simple_event_1)
|
341
|
+
interval = request_times[1] - request_times[0]
|
342
|
+
expect(interval).to be < ((retry_ms.to_f / 1000) + 0.1)
|
343
|
+
end
|
344
|
+
end
|
345
|
+
end
|
346
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
require "ld-eventsource/impl/event_parser"
|
2
|
+
|
3
|
+
describe SSE::Impl::EventParser do
|
4
|
+
subject { SSE::Impl::EventParser }
|
5
|
+
|
6
|
+
it "parses an event with all fields" do
|
7
|
+
lines = [
|
8
|
+
"event: abc\r\n",
|
9
|
+
"data: def\r\n",
|
10
|
+
"id: 1\r\n",
|
11
|
+
"\r\n"
|
12
|
+
]
|
13
|
+
ep = subject.new(lines)
|
14
|
+
|
15
|
+
expected_event = SSE::StreamEvent.new(:abc, "def", "1")
|
16
|
+
output = ep.items.to_a
|
17
|
+
expect(output).to eq([ expected_event ])
|
18
|
+
end
|
19
|
+
|
20
|
+
it "parses an event with only data" do
|
21
|
+
lines = [
|
22
|
+
"data: def\r\n",
|
23
|
+
"\r\n"
|
24
|
+
]
|
25
|
+
ep = subject.new(lines)
|
26
|
+
|
27
|
+
expected_event = SSE::StreamEvent.new(:message, "def", nil)
|
28
|
+
output = ep.items.to_a
|
29
|
+
expect(output).to eq([ expected_event ])
|
30
|
+
end
|
31
|
+
|
32
|
+
it "parses an event with multi-line data" do
|
33
|
+
lines = [
|
34
|
+
"data: def\r\n",
|
35
|
+
"data: ghi\r\n",
|
36
|
+
"\r\n"
|
37
|
+
]
|
38
|
+
ep = subject.new(lines)
|
39
|
+
|
40
|
+
expected_event = SSE::StreamEvent.new(:message, "def\nghi", nil)
|
41
|
+
output = ep.items.to_a
|
42
|
+
expect(output).to eq([ expected_event ])
|
43
|
+
end
|
44
|
+
|
45
|
+
it "ignores comments" do
|
46
|
+
lines = [
|
47
|
+
":",
|
48
|
+
"data: def\r\n",
|
49
|
+
":",
|
50
|
+
"\r\n"
|
51
|
+
]
|
52
|
+
ep = subject.new(lines)
|
53
|
+
|
54
|
+
expected_event = SSE::StreamEvent.new(:message, "def", nil)
|
55
|
+
output = ep.items.to_a
|
56
|
+
expect(output).to eq([ expected_event ])
|
57
|
+
end
|
58
|
+
|
59
|
+
it "parses reconnect interval" do
|
60
|
+
lines = [
|
61
|
+
"retry: 2500\r\n",
|
62
|
+
"\r\n"
|
63
|
+
]
|
64
|
+
ep = subject.new(lines)
|
65
|
+
|
66
|
+
expected_item = SSE::Impl::SetRetryInterval.new(2500)
|
67
|
+
output = ep.items.to_a
|
68
|
+
expect(output).to eq([ expected_item ])
|
69
|
+
end
|
70
|
+
|
71
|
+
it "parses multiple events" do
|
72
|
+
lines = [
|
73
|
+
"event: abc\r\n",
|
74
|
+
"data: def\r\n",
|
75
|
+
"id: 1\r\n",
|
76
|
+
"\r\n",
|
77
|
+
"data: ghi\r\n",
|
78
|
+
"\r\n"
|
79
|
+
]
|
80
|
+
ep = subject.new(lines)
|
81
|
+
|
82
|
+
expected_event_1 = SSE::StreamEvent.new(:abc, "def", "1")
|
83
|
+
expected_event_2 = SSE::StreamEvent.new(:message, "ghi", nil)
|
84
|
+
output = ep.items.to_a
|
85
|
+
expect(output).to eq([ expected_event_1, expected_event_2 ])
|
86
|
+
end
|
87
|
+
|
88
|
+
it "ignores events with no data" do
|
89
|
+
lines = [
|
90
|
+
"event: nothing\r\n",
|
91
|
+
"\r\n",
|
92
|
+
"event: nada\r\n",
|
93
|
+
"\r\n"
|
94
|
+
]
|
95
|
+
ep = subject.new(lines)
|
96
|
+
|
97
|
+
output = ep.items.to_a
|
98
|
+
expect(output).to eq([])
|
99
|
+
end
|
100
|
+
end
|