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