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
@@ -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
|