ld-eventsource 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
@@ -0,0 +1,67 @@
|
|
1
|
+
|
2
|
+
module SSE
|
3
|
+
#
|
4
|
+
# Exception classes used by the SSE client.
|
5
|
+
#
|
6
|
+
module Errors
|
7
|
+
#
|
8
|
+
# An exception class representing an HTTP error response. This can be passed to the error
|
9
|
+
# handler specified in {Client#on_error}.
|
10
|
+
#
|
11
|
+
class HTTPStatusError < StandardError
|
12
|
+
def initialize(status, message)
|
13
|
+
@status = status
|
14
|
+
@message = message
|
15
|
+
super("HTTP error #{status}")
|
16
|
+
end
|
17
|
+
|
18
|
+
# The HTTP status code.
|
19
|
+
# @return [Int]
|
20
|
+
attr_reader :status
|
21
|
+
|
22
|
+
# The response body, if any.
|
23
|
+
# @return [String]
|
24
|
+
attr_reader :message
|
25
|
+
end
|
26
|
+
|
27
|
+
#
|
28
|
+
# An exception class representing an invalid HTTP content type. This can be passed to the error
|
29
|
+
# handler specified in {Client#on_error}.
|
30
|
+
#
|
31
|
+
class HTTPContentTypeError < StandardError
|
32
|
+
def initialize(type)
|
33
|
+
@content_type = type
|
34
|
+
super("invalid content type \"#{type}\"")
|
35
|
+
end
|
36
|
+
|
37
|
+
# The HTTP content type.
|
38
|
+
# @return [String]
|
39
|
+
attr_reader :type
|
40
|
+
end
|
41
|
+
|
42
|
+
#
|
43
|
+
# An exception class indicating that an HTTP proxy server returned an error.
|
44
|
+
#
|
45
|
+
class HTTPProxyError < StandardError
|
46
|
+
def initialize(status)
|
47
|
+
@status = status
|
48
|
+
super("proxy server returned error #{status}")
|
49
|
+
end
|
50
|
+
|
51
|
+
# The HTTP status code.
|
52
|
+
# @return [Int]
|
53
|
+
attr_reader :status
|
54
|
+
end
|
55
|
+
|
56
|
+
#
|
57
|
+
# An exception class indicating that the client dropped the connection due to a read timeout.
|
58
|
+
# This means that the number of seconds specified by `read_timeout` in {Client#initialize}
|
59
|
+
# elapsed without receiving any data from the server.
|
60
|
+
#
|
61
|
+
class ReadTimeoutError < StandardError
|
62
|
+
def initialize(interval)
|
63
|
+
super("no data received in #{interval} seconds")
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
|
2
|
+
module SSE
|
3
|
+
#
|
4
|
+
# Server-Sent Event type used by {Client}. Use {Client#on_event} to receive events.
|
5
|
+
#
|
6
|
+
# @!attribute type
|
7
|
+
# @return [Symbol] the string that appeared after `event:` in the stream;
|
8
|
+
# defaults to `:message` if `event:` was not specified, will never be nil
|
9
|
+
# @!attribute data
|
10
|
+
# @return [String] the string that appeared after `data:` in the stream;
|
11
|
+
# if there were multiple `data:` lines, they are concatenated with newlines
|
12
|
+
# @!attribute id
|
13
|
+
# @return [String] the string that appeared after `id:` in the stream if any, or nil
|
14
|
+
#
|
15
|
+
StreamEvent = Struct.new(:type, :data, :id)
|
16
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
|
2
|
+
module SSE
|
3
|
+
module Impl
|
4
|
+
#
|
5
|
+
# A simple backoff algorithm that can reset itself after a given interval has passed without errors.
|
6
|
+
# A random jitter of up to -50% is applied to each interval.
|
7
|
+
#
|
8
|
+
class Backoff
|
9
|
+
#
|
10
|
+
# Constructs a backoff counter.
|
11
|
+
#
|
12
|
+
# @param [Float] base_interval the minimum value
|
13
|
+
# @param [Float] max_interval the maximum value
|
14
|
+
# @param [Float] reconnect_reset_interval the interval will be reset to the minimum if this number of
|
15
|
+
# seconds elapses between the last call to {#mark_success} and the next call to {#next_interval}
|
16
|
+
#
|
17
|
+
def initialize(base_interval, max_interval, reconnect_reset_interval: 60)
|
18
|
+
@base_interval = base_interval
|
19
|
+
@max_interval = max_interval
|
20
|
+
@reconnect_reset_interval = reconnect_reset_interval
|
21
|
+
@attempts = 0
|
22
|
+
@last_good_time = nil
|
23
|
+
@jitter_rand = Random.new
|
24
|
+
end
|
25
|
+
|
26
|
+
#
|
27
|
+
# The minimum value for the backoff interval.
|
28
|
+
#
|
29
|
+
attr_accessor :base_interval
|
30
|
+
|
31
|
+
#
|
32
|
+
# Computes the next interval value.
|
33
|
+
#
|
34
|
+
# @return [Float] the next interval in seconds
|
35
|
+
#
|
36
|
+
def next_interval
|
37
|
+
if !@last_good_time.nil?
|
38
|
+
good_duration = Time.now.to_f - @last_good_time
|
39
|
+
@attempts = 0 if good_duration >= @reconnect_reset_interval
|
40
|
+
end
|
41
|
+
if @attempts == 0
|
42
|
+
@attempts += 1
|
43
|
+
return 0
|
44
|
+
end
|
45
|
+
@last_good_time = nil
|
46
|
+
target = ([@base_interval * (2 ** @attempts), @max_interval].min).to_f
|
47
|
+
@attempts += 1
|
48
|
+
(target / 2) + @jitter_rand.rand(target / 2)
|
49
|
+
end
|
50
|
+
|
51
|
+
#
|
52
|
+
# Marks the current time as being the beginning of a valid connection state, resetting the timer
|
53
|
+
# that measures how long the state has been valid.
|
54
|
+
#
|
55
|
+
def mark_success
|
56
|
+
@last_good_time = Time.now.to_f
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
require "ld-eventsource/events"
|
2
|
+
|
3
|
+
module SSE
|
4
|
+
module Impl
|
5
|
+
#
|
6
|
+
# Indicates that the SSE server sent a `retry:` field to override the client's reconnection
|
7
|
+
# interval. You will only see this class if you use {EventParser} directly; {Client} will
|
8
|
+
# consume it and not pass it on.
|
9
|
+
#
|
10
|
+
# @!attribute milliseconds
|
11
|
+
# @return [Int] the new reconnect interval in milliseconds
|
12
|
+
#
|
13
|
+
SetRetryInterval = Struct.new(:milliseconds)
|
14
|
+
|
15
|
+
#
|
16
|
+
# Accepts lines of text via an enumerator, and parses them into SSE messages. You will not need
|
17
|
+
# to use this directly if you are using {Client}, but it may be useful for testing.
|
18
|
+
#
|
19
|
+
class EventParser
|
20
|
+
#
|
21
|
+
# Constructs an instance of EventParser.
|
22
|
+
#
|
23
|
+
# @param [Enumerator] lines an enumerator that will yield one line of text at a time
|
24
|
+
#
|
25
|
+
def initialize(lines)
|
26
|
+
@lines = lines
|
27
|
+
reset_buffers
|
28
|
+
end
|
29
|
+
|
30
|
+
# Generator that parses the input iterator and returns instances of {StreamEvent} or {SetRetryInterval}.
|
31
|
+
def items
|
32
|
+
Enumerator.new do |gen|
|
33
|
+
@lines.each do |line|
|
34
|
+
line.chomp!
|
35
|
+
if line.empty?
|
36
|
+
event = maybe_create_event
|
37
|
+
reset_buffers
|
38
|
+
gen.yield event if !event.nil?
|
39
|
+
else
|
40
|
+
case line
|
41
|
+
when /^(\w+): ?(.*)$/
|
42
|
+
item = process_field($1, $2)
|
43
|
+
gen.yield item if !item.nil?
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def reset_buffers
|
53
|
+
@id = nil
|
54
|
+
@type = nil
|
55
|
+
@data = ""
|
56
|
+
end
|
57
|
+
|
58
|
+
def process_field(name, value)
|
59
|
+
case name
|
60
|
+
when "event"
|
61
|
+
@type = value.to_sym
|
62
|
+
when "data"
|
63
|
+
@data << "\n" if !@data.empty?
|
64
|
+
@data << value
|
65
|
+
when "id"
|
66
|
+
@id = value
|
67
|
+
when "retry"
|
68
|
+
if /^(?<num>\d+)$/ =~ value
|
69
|
+
return SetRetryInterval.new(num.to_i)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
nil
|
73
|
+
end
|
74
|
+
|
75
|
+
def maybe_create_event
|
76
|
+
return nil if @data.empty?
|
77
|
+
StreamEvent.new(@type || :message, @data, @id)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
@@ -0,0 +1,222 @@
|
|
1
|
+
require "ld-eventsource/errors"
|
2
|
+
|
3
|
+
require "concurrent/atomics"
|
4
|
+
require "http_tools"
|
5
|
+
require "socketry"
|
6
|
+
|
7
|
+
module SSE
|
8
|
+
module Impl
|
9
|
+
#
|
10
|
+
# Wrapper around a socket providing a simplified HTTP request-response cycle including streaming.
|
11
|
+
# The socket is created and managed by Socketry, which we use so that we can have a read timeout.
|
12
|
+
#
|
13
|
+
class StreamingHTTPConnection
|
14
|
+
attr_reader :status, :headers
|
15
|
+
|
16
|
+
#
|
17
|
+
# Opens a new connection.
|
18
|
+
#
|
19
|
+
# @param [String] uri the URI to connect o
|
20
|
+
# @param [String] proxy the proxy server URI, if any
|
21
|
+
# @param [Hash] headers request headers
|
22
|
+
# @param [Float] connect_timeout connection timeout
|
23
|
+
# @param [Float] read_timeout read timeout
|
24
|
+
#
|
25
|
+
def initialize(uri, proxy: nil, headers: {}, connect_timeout: nil, read_timeout: nil)
|
26
|
+
@socket = HTTPConnectionFactory.connect(uri, proxy, connect_timeout, read_timeout)
|
27
|
+
@socket.write(build_request(uri, headers))
|
28
|
+
@reader = HTTPResponseReader.new(@socket, read_timeout)
|
29
|
+
@status = @reader.status
|
30
|
+
@headers = @reader.headers
|
31
|
+
@closed = Concurrent::AtomicBoolean.new(false)
|
32
|
+
end
|
33
|
+
|
34
|
+
#
|
35
|
+
# Closes the connection.
|
36
|
+
#
|
37
|
+
def close
|
38
|
+
if @closed.make_true
|
39
|
+
@socket.close if @socket
|
40
|
+
@socket = nil
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
#
|
45
|
+
# Generator that returns one line of the response body at a time (delimited by \r, \n,
|
46
|
+
# or \r\n) until the response is fully consumed or the socket is closed.
|
47
|
+
#
|
48
|
+
def read_lines
|
49
|
+
@reader.read_lines
|
50
|
+
end
|
51
|
+
|
52
|
+
#
|
53
|
+
# Consumes the entire response body and returns it.
|
54
|
+
#
|
55
|
+
# @return [String] the response body
|
56
|
+
#
|
57
|
+
def read_all
|
58
|
+
@reader.read_all
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
# Build an HTTP request line and headers.
|
64
|
+
def build_request(uri, headers)
|
65
|
+
ret = "GET #{uri.request_uri} HTTP/1.1\r\n"
|
66
|
+
ret << "Host: #{uri.host}\r\n"
|
67
|
+
headers.each { |k, v|
|
68
|
+
ret << "#{k}: #{v}\r\n"
|
69
|
+
}
|
70
|
+
ret + "\r\n"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
#
|
75
|
+
# Used internally to send the HTTP request, including the proxy dialogue if necessary.
|
76
|
+
# @private
|
77
|
+
#
|
78
|
+
class HTTPConnectionFactory
|
79
|
+
def self.connect(uri, proxy, connect_timeout, read_timeout)
|
80
|
+
if !proxy
|
81
|
+
return open_socket(uri, connect_timeout)
|
82
|
+
end
|
83
|
+
|
84
|
+
socket = open_socket(proxy, connect_timeout)
|
85
|
+
socket.write(build_proxy_request(uri, proxy))
|
86
|
+
|
87
|
+
# temporarily create a reader just for the proxy connect response
|
88
|
+
proxy_reader = HTTPResponseReader.new(socket, read_timeout)
|
89
|
+
if proxy_reader.status != 200
|
90
|
+
raise Errors::HTTPProxyError.new(proxy_reader.status)
|
91
|
+
end
|
92
|
+
|
93
|
+
# start using TLS at this point if appropriate
|
94
|
+
if uri.scheme.downcase == 'https'
|
95
|
+
wrap_socket_in_ssl_socket(socket)
|
96
|
+
else
|
97
|
+
socket
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
private
|
102
|
+
|
103
|
+
def self.open_socket(uri, connect_timeout)
|
104
|
+
if uri.scheme.downcase == 'https'
|
105
|
+
Socketry::SSL::Socket.connect(uri.host, uri.port, timeout: connect_timeout)
|
106
|
+
else
|
107
|
+
Socketry::TCP::Socket.connect(uri.host, uri.port, timeout: connect_timeout)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# Build a proxy connection header.
|
112
|
+
def self.build_proxy_request(uri, proxy)
|
113
|
+
ret = "CONNECT #{uri.host}:#{uri.port} HTTP/1.1\r\n"
|
114
|
+
ret << "Host: #{uri.host}:#{uri.port}\r\n"
|
115
|
+
if proxy.user || proxy.password
|
116
|
+
encoded_credentials = Base64.strict_encode64([proxy.user || '', proxy.password || ''].join(":"))
|
117
|
+
ret << "Proxy-Authorization: Basic #{encoded_credentials}\r\n"
|
118
|
+
end
|
119
|
+
ret << "\r\n"
|
120
|
+
ret
|
121
|
+
end
|
122
|
+
|
123
|
+
def self.wrap_socket_in_ssl_socket(socket)
|
124
|
+
io = IO.try_convert(socket)
|
125
|
+
ssl_sock = OpenSSL::SSL::SSLSocket.new(io, OpenSSL::SSL::SSLContext.new)
|
126
|
+
ssl_sock.connect
|
127
|
+
Socketry::SSL::Socket.new.from_socket(ssl_sock)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
#
|
132
|
+
# Used internally to read the HTTP response, either all at once or as a stream of text lines.
|
133
|
+
# Incoming data is fed into an instance of HTTPTools::Parser, which gives us the header and
|
134
|
+
# chunks of the body via callbacks.
|
135
|
+
# @private
|
136
|
+
#
|
137
|
+
class HTTPResponseReader
|
138
|
+
DEFAULT_CHUNK_SIZE = 10000
|
139
|
+
|
140
|
+
attr_reader :status, :headers
|
141
|
+
|
142
|
+
def initialize(socket, read_timeout)
|
143
|
+
@socket = socket
|
144
|
+
@read_timeout = read_timeout
|
145
|
+
@parser = HTTPTools::Parser.new
|
146
|
+
@buffer = ""
|
147
|
+
@done = false
|
148
|
+
@lock = Mutex.new
|
149
|
+
|
150
|
+
# Provide callbacks for the Parser to give us the headers and body. This has to be done
|
151
|
+
# before we start piping any data into the parser.
|
152
|
+
have_headers = false
|
153
|
+
@parser.on(:header) do
|
154
|
+
have_headers = true
|
155
|
+
end
|
156
|
+
@parser.on(:stream) do |data|
|
157
|
+
@lock.synchronize { @buffer << data } # synchronize because we're called from another thread in Socketry
|
158
|
+
end
|
159
|
+
@parser.on(:finish) do
|
160
|
+
@lock.synchronize { @done = true }
|
161
|
+
end
|
162
|
+
|
163
|
+
# Block until the status code and headers have been successfully read.
|
164
|
+
while !have_headers
|
165
|
+
raise EOFError if !read_chunk_into_buffer
|
166
|
+
end
|
167
|
+
@headers = Hash[@parser.header.map { |k,v| [k.downcase, v] }]
|
168
|
+
@status = @parser.status_code
|
169
|
+
end
|
170
|
+
|
171
|
+
def read_lines
|
172
|
+
Enumerator.new do |gen|
|
173
|
+
loop do
|
174
|
+
line = read_line
|
175
|
+
break if line.nil?
|
176
|
+
gen.yield line
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
def read_all
|
182
|
+
while read_chunk_into_buffer
|
183
|
+
end
|
184
|
+
@buffer
|
185
|
+
end
|
186
|
+
|
187
|
+
private
|
188
|
+
|
189
|
+
# Attempt to read some more data from the socket. Return true if successful, false if EOF.
|
190
|
+
# A read timeout will result in an exception from Socketry's readpartial method.
|
191
|
+
def read_chunk_into_buffer
|
192
|
+
# If @done is set, it means the Parser has signaled end of response body
|
193
|
+
@lock.synchronize { return false if @done }
|
194
|
+
begin
|
195
|
+
data = @socket.readpartial(DEFAULT_CHUNK_SIZE, timeout: @read_timeout)
|
196
|
+
rescue Socketry::TimeoutError
|
197
|
+
# We rethrow this as our own type so the caller doesn't have to know the Socketry API
|
198
|
+
raise Errors::ReadTimeoutError.new(@read_timeout)
|
199
|
+
end
|
200
|
+
return false if data == :eof
|
201
|
+
@parser << data
|
202
|
+
# We are piping the content through the parser so that it can handle things like chunked
|
203
|
+
# encoding for us. The content ends up being appended to @buffer via our callback.
|
204
|
+
true
|
205
|
+
end
|
206
|
+
|
207
|
+
# Extract the next line of text from the read buffer, refilling the buffer as needed.
|
208
|
+
def read_line
|
209
|
+
loop do
|
210
|
+
@lock.synchronize do
|
211
|
+
i = @buffer.index(/[\r\n]/)
|
212
|
+
if !i.nil?
|
213
|
+
i += 1 if (@buffer[i] == "\r" && i < @buffer.length - 1 && @buffer[i + 1] == "\n")
|
214
|
+
return @buffer.slice!(0, i + 1).force_encoding(Encoding::UTF_8)
|
215
|
+
end
|
216
|
+
end
|
217
|
+
return nil if !read_chunk_into_buffer
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|