ld-eventsource 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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